#!/usr/bin/env python3
"""ManageSieve client"""

#
# Copyright 2023 and 2024  Odin Kroeger
#
# This file is part of SieveManager.
#
# SieveManager is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
#
# SieveManager is distributed in the hope that it will be useful,
# but WITHOUT ALL WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with SieveManager. If not, see <https://www.gnu.org/licenses/>.
#


#
# Modules
#

from __future__ import annotations
from abc import ABC, abstractmethod
from base64 import b64decode, b64encode
from collections import UserDict, defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from errno import EEXIST, EISCONN, ENOENT, ENOTCONN
from fcntl import LOCK_SH, LOCK_NB
from getopt import GetoptError, getopt
from inspect import Parameter
from os import O_CREAT, O_EXCL, O_WRONLY, O_TRUNC, SEEK_END, SEEK_SET
from os import path
from signal import SIG_DFL, SIG_IGN, SIGHUP, SIGINT, SIGTERM
from tempfile import SpooledTemporaryFile, TemporaryDirectory
from typing import (Any, BinaryIO, Callable, ClassVar, Final, IO, Iterator,
                    Optional, Union, Sequence, TextIO, TypeVar)

import code
import contextlib
import dataclasses
import datetime
import enum
import fcntl
import fnmatch
import functools
import getpass
import hashlib
import hmac
import inspect
import io
import ipaddress
import itertools
import locale
import logging
import math
import netrc
import os
import pwd
import random
import re
import readline
import secrets
import select
import shlex
import shutil
import signal
import socket
import subprocess   # nosec B404
import ssl
import string
import stringprep
import sys
import threading
import types
import unicodedata
import urllib.parse
import urllib.request

try:
    from cryptography import x509
    from cryptography.hazmat.primitives.hashes import SHA1
    from cryptography.hazmat.primitives.serialization import Encoding
    from cryptography.x509 import (AuthorityInformationAccess,
                                   ExtensionNotFound, ocsp)
    from cryptography.x509.oid import AuthorityInformationAccessOID
    from cryptography.x509.ocsp import OCSPResponseStatus, OCSPCertStatus
except ImportError:
    pass

try:
    import dns
    from dns.exception import DNSException
except ImportError:
    pass

if sys.version_info < (3, 9):
    sys.exit('SieveManager requires Python 3.9 or later')


#
# Metadata
#

__version__ = '0.7.3'
__author__ = 'Odin Kroeger'
__copyright__ = '2023 and 2024 Odin Kroeger'
__license__ = 'GPLv3+'
__all__ = [
    # ACAP
    'BaseACAPConn',
    'ACAPAtom',
    'ACAPWord',
    'ACAPLine',

    # ManageSieve
    'SieveConn',
    'SieveManager',
    'SieveSASLAdapter',
    'Capabilities',
    'Response',
    'URL',

    # SASL
    'BaseAuth',
    'BasePassAuth',
    'BaseScramAuth',
    'BaseScramPlusAuth',
    'AuthzUnsupportedMixin',
    'CramMD5Auth',
    'ExternalAuth',
    'LoginAuth',
    'PlainAuth',
    'ExternalAuth',
    'ScramSHA1Auth',
    'ScramSHA1PlusAuth',
    'ScramSHA224Auth',
    'ScramSHA224PlusAuth',
    'ScramSHA256Auth',
    'ScramSHA256PlusAuth',
    'ScramSHA384Auth',
    'ScramSHA384PlusAuth',
    'ScramSHA512Auth',
    'ScramSHA512PlusAuth',
    'ScramSHA3_512Auth',
    'ScramSHA3_512PlusAuth',
    'AuthMech',
    'AuthState',
    'SASLPrep',

    # Abstract base classes
    'AbstractSASLAdapter',
    'AbstractAuth',

    # Errors
    'Error',
    'CapabilityError',
    'ConfigError',
    'DataError',
    'OperationError',
    'ProtocolError',
    'SecurityError',
    'UsageError',
    'ClientError',
    'ClientConnectionError',
    'ClientOperationError',
    'DNSError',
    'DNSOperationError',
    'OCSPError',
    'OCSPDataError',
    'OCSPOperationError',
    'SASLError',
    'SASLCapabilityError',
    'SASLProtocolError',
    'SASLSecurityError',
    'SieveError',
    'SieveCapabilityError',
    'SieveConnectionError',
    'SieveOperationError',
    'SieveProtocolError',
    'TLSError',
    'TLSCapabilityError',
    'TLSSecurityError',
]


#
# Globals
#

ABOUT: Final[str] = f"SieveManager {__version__}\nCopyright {__copyright__}"
"""About message."""

DEBUG: bool = False
"""Print stack traces even for expected error types?"""

EDITOR: Final[list[str]] = shlex.split(os.getenv('EDITOR', 'ed'), posix=True)
""":envvar:`EDITOR` or :command:`ed` if :envvar:`EDITOR` is unset."""

HOME: Final[str] = os.getenv('HOME', pwd.getpwuid(os.getuid()).pw_dir)
"""Home directory."""

PAGER: Final[list[str]] = shlex.split(os.getenv('PAGER', 'more'), posix=True)
""":envvar:`PAGER` or :command:`more` if :envvar:`PAGER` is unset."""

VISUAL: Final[list[str]] = shlex.split(os.getenv('VISUAL', 'vi'), posix=True)
""":envvar:`VISUAL` or :command:`vi` if :envvar:`VISUAL` is unset."""

XDG_CONFIG_HOME: Final[str] = os.getenv('XDG_CONFIG_HOME', f'{HOME}/.config')
"""X Desktop group base configuration directory."""

CONFIGFILES: Final[tuple[str, ...]] = (
    '/etc/sieve/config',
    '/etc/sieve.cf',
    f'{XDG_CONFIG_HOME}/sieve/config',
    f'{HOME}/.sieve/config',
    f'{HOME}/.sieve.cf'
)
"""Default configuration files."""


#
# Types
#

AuthMech = type['AbstractAuth']
"""Alias for subclasses of :class:`AbstractAuth`."""


class AuthState(enum.IntEnum):
    """State of the authentication process."""

    PREAUTH = enum.auto()
    """"AUTHENTICATE" has *not* been issued."""

    SENT = enum.auto()
    """Data sent, ready to receive."""

    RECEIVED = enum.auto()
    """Data received, ready to send."""

    DONE = enum.auto()
    """Authentication concluded."""


class LogLevel(enum.IntEnum):
    """Logging levels."""

    AUTH = logging.DEBUG // 2
    DEBUG = logging.DEBUG
    INFO = logging.INFO
    WARNING = logging.WARNING
    ERROR = logging.ERROR

    def fromdelta(self, delta: int) -> 'LogLevel':
        """Get a :class:`LogLevel` from `delta`."""
        levels = list(self.__class__)
        index = levels.index(self) - delta
        return levels[min(max(index, 0), len(levels) - 1)]


class ConfirmEnum(enum.IntEnum):
    """Confirmation types."""

    NO = 0
    YES = 1
    ALL = 2
    NONE = 3

    def __bool__(self) -> bool:
        return self in (self.YES, self.ALL)


class SASLPrep(enum.IntEnum):
    """Controls which strings are prepared for authentication.

    .. seealso::
        :RFC:`4013`
            SASLPrep
        :RFC:`4422` (sec. 4)
            SASL protocol requirements
    """

    NONE = 0
    USERNAMES = 1
    PASSWORDS = 2
    ALL = 3


class ShellCmd(enum.IntEnum):
    """Shell actions that may overwrite or remove files."""

    NONE = 0
    CP = 1
    GET = 2
    MV = 4
    PUT = 8
    RM = 16
    ALL = 31


class ShellPattern(str):
    """:class:`BaseShell` pattern."""

    def __add__(self, other: Union['ShellPattern', str]) -> 'ShellPattern':
        return self.__class__(super().__add__(other))

    def __radd__(self, other: Union['ShellPattern', str]) -> 'ShellPattern':
        return self.__class__(other.__add__(self))


ShellWord = Union[ShellPattern, str]
"""Alias for :class:`ShellPattern` and `str`."""


T = TypeVar('T')
"""Type variable."""


#
# Abstract base classes
#

AbstractAuthT = TypeVar('AbstractAuthT', bound='AbstractAuth')
"""Type variable for :class:`AbstractAuth`."""


class AbstractAuth(ABC):
    """Abstract base class for SASL mechanisms.

    .. seealso::
        https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml
            Authentication mechanisms registered with IANA_.
    """

    @abstractmethod
    def __init__(self, adapter: 'AbstractSASLAdapter',
                 authcid: str, authzid: str = '',
                 prepare: SASLPrep = SASLPrep.ALL):
        """Prepare authentication.

        Arguments:
            authcid: Authentication ID (user to login as).
            authzid: Authorisation ID (use whose rights to acquire).
            prepare: Which credentials to prepare.

        Raises:
            ValueError: Bad characters in username.
        """

    @abstractmethod
    def __call__(self) -> Optional[Any]:
        """Perform authentication.

        Returns:
            Data returned by the server, if any.
        """

    @classmethod
    def getauthtypes(cls: type[AbstractAuthT],
                     mechs: Iterable[type[AbstractAuthT]]) \
            -> list[type[AbstractAuthT]]:
        """Get the common base classes of `mechs` that are subclasses of `cls`.

        Returns:
            Classes are sorted by inheritence depth,
            from super- to subclasses.

            `cls` itself is not returned.
        """
        bases = (frozenset(m.__mro__) for m in mechs)
        common = functools.reduce(frozenset.intersection, bases)
        subs = filter(lambda c: issubclass(c, cls) and c != cls, common)
        return sorted(subs, key=lambda c: len(c.__mro__))

    name: ClassVar[str]
    """Mechanism name."""

    authcid: str
    """Authentication ID (user to login as)."""

    authzid: str = ''
    """Authorisation ID (user whose rights to acquire)."""


class AbstractSASLAdapter(ABC):
    """Abstract base class for sending/receiving SASL messages.

    .. seealso::
        :RFC:`4422`
            Simple Authentication and Security Layer (SASL)
    """

    @abstractmethod
    def abort(self):
        """Abort authentication."""

    @abstractmethod
    def begin(self, name: str, data: Optional[bytes] = None):
        """Begin authentication.

        Arguments:
            name: SASL mechanism name.
            data: Optional client-first message.
        """

    @abstractmethod
    def end(self):
        """Conclude authentication.

        Raises:
            OperationError: Authentication failed.
        """

    @abstractmethod
    def send(self, data: bytes):
        """Encode and send an SASL message."""

    @abstractmethod
    def receive(self) -> bytes:
        """Receive and decode an SASL message."""

    @property
    @abstractmethod
    def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
        """Underlying socket."""

    @sock.setter
    @abstractmethod
    def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
        pass


#
# ACAP
#

class BaseACAPConn(ABC):
    """Application Control Access Protocol (ACAP) parser/serialiser.

    ManageSieve builds on the syntax and data types of ACAP.

    .. seealso::
        :RFC:`2244` (secs. 2.2 and 2.6)
            ACAP commands, responses, and data formats.
        :RFC:`5804` (secs. 1.2 and 4)
            ManageSieve syntax.
    """

    def receiveline(self) -> 'ACAPLine':
        """Receive a line and parse it.

        ==================  =================
        ACAP type           Python type
        ==================  =================
        Atom                :class:`ACAPAtom`
        Literal             :class:`str`
        Nil                 ``None``
        Number              :class:`int`
        Parenthesised List  :class:`list`
        String              :class:`str`
        ==================  =================

        For example:

        >>> acap.sendline(ACAPAtom('listscripts'))
        >>> acap.receiveline()
        ['foo.sieve', 'ACTIVE']
        >>> acap.receiveline()
        ['bar.sieve']
        >>> acap.receiveline()
        ['baz.sieve']
        >>> acap.receiveline()
        ['OK', 'Listscripts completed.']
        """
        assert self.file
        words: ACAPLine = []
        stack: list[ACAPLine] = [words]
        ptr: ACAPLine = words
        while line := self.file.readline().decode('utf8'):
            size: int = -1
            for token in self._lex(line):
                key = token.lastgroup
                value = token.group(key)    # type: ignore[arg-type]
                if key == 'leftparen':
                    parens: list[ACAPWord] = []
                    stack.append(ptr)
                    ptr.append(parens)
                    ptr = parens
                elif key == 'rightparen':
                    ptr = stack.pop()
                elif key == 'atom':
                    if value.casefold() == 'nil':
                        ptr.append(None)
                    else:
                        ptr.append(ACAPAtom(value))
                elif key == 'number':
                    ptr.append(int(value))
                elif key == 'string':
                    ptr.append(value)
                elif key == 'literal':
                    size = int(value)
                    literal = self.file.read(size).decode('utf8')
                    ptr.append(literal)
            if size == -1:
                break
        return words

    def sendline(self, *objs: Union[IO[Any], 'ACAPWord'], whole: bool = True):
        """Convert objects to ACAP types and send them as a line.

        ==================  ======================================
        Python type         ACAP type
        ==================  ======================================
        :class:`ACAPAtom`   Atom
        :class:`typing.IO`  Literal
        ``None``            Nil
        :class:`bytes`      Literal or String [#literal]_
        :class:`list`       Parenthesised List
        :class:`int`        Number [#nums]_
        :class:`str`        Literal or String [#literal]_ [#utf8]_
        ==================  ======================================

        For example:

        >>> acap.sendline(ACAPAtom('havespace'), 'script.sieve', 12345)
        >>> acap.receiveline()
        ['OK', 'Putscript would succeed.']

        Pipeline commands:

        >>> conn.isalive(check=True)
        >>> with open('foo.sieve') as script:
        >>>     conn.sendline(ACAPAtom('putscript', script, script.name))
        >>> conn.sendline(ACAPAtom('logout'))
        >>> for _ in range(2):
        >>>     conn.collect(check=True)

        Arguments:
            objs: Objects to serialise.
            whole: Conclude output with CRLF?

        Raises:
            ValueError: Number is not in the range [0, 4,294,967,295].
            TypeError: Some object cannot be represented as ACAP data type.

        .. [#literal] Depending on content.
        .. [#nums] Numbers must be representable as unsigned 32-bit integers.
        .. [#utf8] Strings are encoded in UTF-8 and normalised to form C.
        """
        assert self.file

        write = self.file.write
        normalize = unicodedata.normalize
        matchstr = self._matchstr

        def encode(s: str) -> bytes:
            return normalize('NFC', s).encode('utf8')

        def addstring(b: bytes):
            write(b'"%s"' % b if matchstr(b) else
                  b'{%d+}\r\n%s' % (len(b), b))

        for i, obj in enumerate(objs):
            if i > 0:
                write(b' ')
            if obj is None:
                write(b'NIL')
            elif isinstance(obj, ACAPAtom):
                write(encode(obj))
            elif isinstance(obj, int):
                if not 0 <= obj < 4_294_967_296:
                    raise ValueError(f'{obj}: not in [0, 4,294,967,295]')
                write(encode(str(obj)))
            elif isinstance(obj, bytes):
                addstring(obj)
            elif isinstance(obj, str):
                addstring(encode(obj))
            elif isinstance(obj, (IO, io.IOBase, SpooledTemporaryFile)):
                write(b'{%d+}\r\n' % getfilesize(obj))
                while block := obj.read(io.DEFAULT_BUFFER_SIZE):
                    write(encode(block) if isinstance(block, str) else block)
            elif isinstance(obj, (Iterator, Sequence)):
                write(b'(')
                self.sendline(*obj, whole=False)
                write(b')')
            else:
                raise TypeError(type(obj).__name__ + ': not an ACAP type')

        if whole:
            write(b'\r\n')
            self.file.flush()

    @property
    @abstractmethod
    def file(self) -> Optional[IO]:
        """File-like access to the underlying connection."""

    _lex: Callable[..., Iterator[re.Match]] = re.compile('|'.join((
        r'(?P<atom>[a-z][^(){\s\\]*)',
        r'(?P<number>\d+)',
        r'"(?P<string>[^\0\r\n"]*)"',
        r'{(?P<literal>\d+)\+?}',
        r'(?P<leftparen>\()',
        r'(?P<rightparen>\))',
    )), flags=re.IGNORECASE).finditer
    """Split a string into ACAP tokens."""

    # Strings may be 1024 octets long, but I limit the length to 1020 octets
    # to allow for errors (two octets for quotations marks, one for the
    # terminating null byte, one for other off-by-one errors).
    _matchstr: Callable[..., Optional[re.Match]] = re.compile(
        br'[^\0\r\n"]{0,1020}'
    ).fullmatch
    """Check whether bytes can be represented as ACAP string."""


class ACAPAtom(str):
    """ACAP keyword."""


ACAPWord = Union[ACAPAtom, None, int, str, 'ACAPLine']
"""ACAP word."""


ACAPLine = list[ACAPWord]
""":class:`List <list>` of ACAP :class:`ACAPWord`-s."""


#
# ManageSieve
#

class SieveConn(BaseACAPConn):
    """Connection to a ManageSieve server.

    For example:

    >>> conn = SieveConn('imap.foo.example', 'user', 'password')
    >>> with open('script.sieve', 'br') as file:
    >>>     res = conn.execute('putscript', file.name, file)
    >>>     if res.response != 'ok':
    >>>         raise res.toerror()
    >>> res = conn.execute('logout')
    >>> if res.response != 'ok':
    >>>     raise res.toerror()

    .. note::
        :class:`SieveConnect` provides a low-level interface.
        You likely want to use :class:`SieveManager` instead.

    .. warning::
        :class:`SieveConn` is not thread-safe.

    .. seealso::
        :RFC:`2244`
            Application Configuration Access Protocol
        :RFC:`2782`
            DNS `SRV records`_
        :RFC:`5804`
            ManageSieve
    """

    def __init__(self, *args, **kwargs):
        """:meth:`Open <open>` a connection to a ManageSieve server.

        Arguments:
            args: Positional arguments for :meth:`open`.
            kwargs: Keyword arguments for :meth:`open`.
        """
        self.lock = threading.Lock()
        self.open(*args, **kwargs)

    def __del__(self):
        """Shut the connection down."""
        try:
            self.shutdown()
        except OSError:
            pass

    def authenticate(self, login: str, *args, owner: str = '',
                     sasl: Iterable[AuthMech] = (), logauth: bool = False,
                     **kwargs):
        """Authenticate as `login`.

        `args` and `kwargs` are passed to the constructors
        of the SASL mechanisms given in `sasl`.

        For example, password-based mechanisms require a password:

        >>> conn.authenticate('user', 'password')

        By contrast, the "EXTERNAL" mechanism requires no credentials:

        >>> conn.authenticate('user', sasl=[ExternalAuth])

        By default, authentication is attempted with password-based mechanisms.

        Arguments:
            login: User to login as (authentication ID).
            owner: User whose scripts to manage (authorisation ID).
            sasl: SASL mechanisms (default: non-obsolete, password-based).
            logauth: Log the authentication exchange?
            args: Positional arguments for SASL mechanism constructors.
            kwargs: Keyword arguments for SASL mechanism constructors.

        Raises:
            ClientOperationError: Another operation is in progress.
            SASLCapabilityError: Authentication mechanisms exhausted.
            SieveConnectionError: :attr:`sock` has died.
            SieveOperationError: Authentication failed.
            SieveProtocolError: Server violated the ManageSieve protocol.
            ValueError: Bad characters in credentials.
        """
        kwargs['authzid'] = owner
        logger = self.logger
        self.auth = args
        self.kwauth = kwargs
        self.sasl = sasl if sasl else BasePassAuth.getmechs()
        self.logauth = logauth
        while True:
            # pylint: disable=consider-using-with
            if not self.lock.acquire(blocking=False):
                raise ClientOperationError('other operation ongoing')
            if isinstance(self.file, LogIOWrapper) and not logauth:
                self.file.quiet = True
            try:
                for mech in self.sasl:
                    assert self.capabilities
                    if mech.name.casefold() in self.capabilities.sasl:
                        conn = SieveSASLAdapter(self)
                        try:
                            auth = mech(conn, login, *args, **kwargs)
                            if caps := auth():
                                self.capabilities = caps
                        except SASLCapabilityError as err:
                            logger.error(err)
                            continue
                        except SieveOperationError as err:
                            # TRANSITION-NEEDED need not be treated specially.
                            if err.matches('auth-too-weak', 'encrypt-needed',
                                           'transition-needed'):
                                logger.debug(err)
                                continue
                            raise
                        if login := auth.authcid:
                            self.login = login
                            logger.info('authenticated as %s', login)
                        if owner := auth.authzid:
                            self.owner = owner
                            logger.info('authorised as %s', owner)
                        return
                raise SASLCapabilityError('SASL mechanisms exhausted')
            except SieveConnectionError as err:
                if err.matches('referral'):
                    try:
                        url = err.code[1]
                    except ValueError as exc:
                        raise SieveProtocolError('unexpected data') from exc
                    if isinstance(url, ACAPAtom) or not isinstance(url, str):
                        # pylint: disable=raise-missing-from
                        raise SieveProtocolError('expected string')
                    self._follow(url)
                    continue
                raise
            finally:
                if isinstance(self.file, LogIOWrapper):
                    self.file.quiet = False
                self.lock.release()
            # NOTREACHED

    def close(self):
        """Close the client-side of the connection.

        .. warning::
            Should only be called if the server has closed the connection.
        """
        if self.file is not None:
            self.file.close()
            self.file = None
        if self.poll is not None:
            assert self.sock
            self.poll.unregister(self.sock)
            self.poll = None
        if self.sock is not None:
            self.sock.close()
            self.sock = None
        self.capabilities = None
        self.host = None
        self.port = None
        self.login = ''
        self.owner = ''
        self.auth = ()
        self.kwauth = {}

    def collect(self, check: bool = False) \
            -> tuple['Response', list[ACAPLine]]:
        """Collect the server's response to the last transmission.

        For example:

        >>> conn.sendline(ACAPAtom('listscripts'))
        >>> conn.collect()
        (Response(response='ok', code=(), data=(), message=None),
         [['foo.sieve', 'ACTIVE'], ['bar.sieve'], ['baz.sieve']])

        Arguments:
            check: Raise an error if the response is not "OK"?

        Raises:
            SieveConnectionError: Server said "BYE". [#collect-check]_
            SieveOperationError: Server said "NO". [#collect-check]_

        .. [#collect-check] Only raised if `check` is `True`.
        """
        lines: list[ACAPLine] = []
        while True:
            line = self.receiveline()
            if line and isinstance(line[0], ACAPAtom):
                res = Response.fromline(line)
                if check and res.response != 'ok':
                    raise res.toerror()
                self.warning = res.message if res.matches('warnings') else None
                return res, lines
            lines.append(line)
        # NOTREACHED

    def execute(self, command: str, *args: Union[IO, ACAPWord]) \
            -> tuple['Response', list[ACAPLine]]:
        """Execute `command` and return the server's response.

        For example:

            >>> conn.execute('listscripts')
            (Response(response='ok', code=(), data=(), message=None),
             [['foo.sieve', 'ACTIVE'], ['bar.sieve'], ['baz.sieve']])

        Raises:
            ClientOperationError: Another operation is in progress.
            SieveConnectionError: Server said "BYE".
            SieveOperationError: Server said "NO".
            SieveProtocolError: Server violated the ManageSieve protocol.

        .. note::
            Referrals are followed automatically.
        """
        assert command
        while True:
            # pylint: disable=consider-using-with
            if not self.lock.acquire(blocking=False):
                raise ClientOperationError('other operation ongoing')
            try:
                self.isalive(check=True)
                self.sendline(ACAPAtom(command.upper()), *args)
                res, data = self.collect()
            finally:
                self.lock.release()
            if res.response == 'ok':
                return res, data
            if res.response == 'no':
                raise res.toerror()
            if res.response == 'bye':
                if not res.matches('referral'):
                    raise res.toerror()
                try:
                    url = res.code[1]
                except ValueError as exc:
                    raise SieveProtocolError('unexpected data') from exc
                if isinstance(url, ACAPAtom) or not isinstance(url, str):
                    # pylint: disable=raise-missing-from
                    raise SieveProtocolError('expected string')
                self._follow(url)
            else:
                raise SieveProtocolError(f'unexpected response: {res}')
        # NOTREACHED

    def getstate(self) -> tuple[list, dict[str, Any]]:
        """Get arguments that would re-:meth:`open` the current connection.

        For example:

        >>> conn.geturl()
        sieve://user@imap.foo.example
        >>> args, kwargs = conn.getstate()
        >>> conn.shutdown()
        >>> conn.geturl()
        >>> conn.open(*args, **kwargs)
        >>> conn.geturl()
        sieve://user@imap.foo.example
        """
        args = []
        kwargs = {}
        signature = inspect.signature(self.open)
        for name, param in list(signature.parameters.items()):
            value = getattr(self, name)
            if param.kind in (Parameter.POSITIONAL_ONLY,
                              Parameter.POSITIONAL_OR_KEYWORD):
                args.append(value)
            elif param.kind == Parameter.VAR_POSITIONAL:
                args.extend(value)
            elif param.kind == Parameter.KEYWORD_ONLY:
                kwargs[name] = value
            elif param.kind == Parameter.VAR_KEYWORD:
                kwargs.update(value)
        return (args, kwargs)

    def geturl(self) -> Optional['URL']:
        """URL of the current connection.

        For example:

        >>> conn = SieveConn('imap.foo.example', 'user', 'password')
        >>> conn.geturl()
        'sieve://user@imap.foo.example'

        .. note::
            Only changes to the connection state effected by :meth:`open`,
            :meth:`close`, :meth:`shutdown`, and referrals are tracked.
        """
        if self.host:
            return URL(hostname=self.host,
                       port=self.port if self.port != 4190 else None,
                       username=self.login,
                       owner=self.owner)
        return None

    def isalive(self, check: bool = False) -> bool:
        """Check whether :attr:`sock` is alive.

        Arguments:
            check: Raise an error if :attr:`sock` is dead?

        Raises:
            ClientConnectionError: :attr:`sock` is dead. [#isalive-check]_

        .. [#isalive-check] Only raised if `check` is `True`.
        """
        assert self.poll
        (_, events), = self.poll.poll()
        try:
            if events & select.POLLERR:
                raise ClientConnectionError(ENOTCONN, 'socket error')
            if events & select.POLLHUP:
                raise ClientConnectionError(ENOTCONN, 'server hung up')
            if events & select.POLLNVAL:
                raise ClientConnectionError(ENOTCONN, os.strerror(ENOTCONN))
        except ClientConnectionError:
            if check:
                raise
        return True

    # pylint: disable=redefined-outer-name,too-many-arguments
    def open(self, host: str, login: str, *auth,
             port: int = 4190, source: tuple[str, int] = ('', 0),
             timeout: Optional[float] = socket.getdefaulttimeout(),
             tls: bool = True, ocsp: bool = True, owner: str = '',
             sasl: Iterable[AuthMech] = (), logauth: bool = False,
             **kwauth):
        """Connect to `host` and login as `login`.

        Connect and login:

        >>> conn.open('imap.foo.example', 'user', 'password')

        Defer login:

        >>> conn.open('imap.foo.example', '')
        >>> conn.authenticate('user', 'password')

        Arguments:
            host: Server address.
            login: User to login as (authentication ID).
                Set to the empty string to defer login.
            auth: Positional arguments for :meth:`authenticate`.
            port: Server port.
            source: Source address and port.
            timeout: Timeout in seconds.
            tls: Secure the connection?
            ocsp: Check whether the server certificate was revoked?
            owner: User whose scripts to manage (authorisation ID).
            sasl: See :meth:`authenticate`.
            logauth: See :meth:`authenticate`.
            kwauth: Keywords arguments for :meth:`authenticate`.

        Raises:
            ClientConnectionError: :attr:`sock` is dead.
            ClientOperationError: Another operation is in progress.
            DNSOperationError: `host` could not be found.
            SASLCapabilityError: Authentication mechanisms exhausted.
            SieveCapabilityError: "STARTTLS" not supported.
            SieveOperationError: Authentication failed.
            ValueError: Bad characters in credentials.
        """
        # pylint: disable=consider-using-with
        if not self.lock.acquire(blocking=False):
            raise ClientOperationError('other operation ongoing')
        try:
            if self.sock or self.file:
                raise ClientConnectionError(EISCONN, os.strerror(EISCONN))
            try:
                if 'dns.resolver' not in sys.modules:
                    raise DNSOperationError('dnspython unavailable')
                if isipaddr(host):
                    raise DNSOperationError('{host}: not a hostname')
                # This is the algorithm specified by RFC 2782, NOT the one
                # specified by RFC 5804, sec. 1.8, which is wrong.
                try:
                    for rec in resolvesrv(f'_sieve._tcp.{host}.'):
                        try:
                            self._connect(rec.host, rec.port, source, timeout)
                        except OSError:
                            continue
                        break
                except DNSException as err:
                    # DNSException could raise a NameError outside this block.
                    raise DNSOperationError(f'{host}: not found') from err
            except DNSOperationError:
                self._connect(host, port, source, timeout)
            _, lines = self.collect(check=True)
            self.capabilities = Capabilities.fromlines(lines)
            self.source = source
            self.logger.info('connected to %s:%d', host, port)
        finally:
            self.lock.release()
        if tls:
            self.starttls(ocsp=ocsp)
        if login:
            self.authenticate(login, *auth, owner=owner, sasl=sasl,
                              logauth=logauth, **kwauth)

    def shutdown(self):
        """Shut the connection down.

        .. note::
            Should only be used if logging out would be unsafe.
        """
        if self.sock is not None:
            self.sock.shutdown(socket.SHUT_RDWR)
            self.logger.info('shut connection down')
        self.close()

    # pylint: disable=redefined-outer-name
    def starttls(self, ocsp: bool = True):
        """Start TLS encryption.

        Arguments:
            ocsp: Check whether the server certificate was revoked?

        Raises:
            ClientOperationError: Another operation is in progress.
            SieveCapabilityError: "STARTTLS" not supported.
            TLSSecurityError: Server certificate has been revoked.

        .. note::
            Called automatically by :meth:`open` unless `tls` is `False`.
        """
        assert self.sock
        assert self.capabilities
        if not self.capabilities.starttls:
            raise SieveCapabilityError('STARTTLS: not supported')
        self.execute('STARTTLS')
        # pylint: disable=consider-using-with
        if not self.lock.acquire(blocking=False):
            raise ClientOperationError('other operation ongoing')
        try:
            self.sock = self.sslcontext.wrap_socket(
                self.sock,  # type: ignore[arg-type]
                server_hostname=self.host
            )
            file = self.sock.makefile('rwb')
            self.file = LogIOWrapper.wrapfile(file, logger=self.logger)
            _, lines = self.collect(check=True)
            self.capabilities = Capabilities.fromlines(lines)
            self.ocsp = ocsp
            proto = self.sock.version()
            cipher = self.sock.cipher()
            if proto and cipher:
                self.logger.info('started %s using %s', proto, cipher[0])
            if ocsp:
                if 'cryptography' in sys.modules:
                    der = self.sock.getpeercert(binary_form=True)
                    cert = x509.load_der_x509_certificate(der)
                    if certrevoked(cert, logger=self.logger):
                        host = self.host
                        raise TLSSecurityError(f'{host}: certificate revoked')
                else:
                    self.logger.warning('module "cryptography": not found')
        finally:
            self.lock.release()

    def withreopen(self, func: Callable[..., T], *args, **kwargs) -> T:
        """Call `func` and retry if the connection is closed.

        For example:

        >>> with open('foo.sieve', 'r') as script:
        >>>     conn.withreopen(conn.execute, 'putscript', script.name, script)

        Arguments:
            func: Function to call.
            args: Positional arguments for `func`.
            kwargs: Keyword arguments for `func`.

        Returns:
            The return value of `func`.
        """
        oargs, okwargs = self.getstate()
        for i in itertools.count():
            try:
                retval = func(*args, **kwargs)
                break
            except (ConnectionError, TimeoutError,
                    socket.herror, socket.gaierror):
                if i > 0:
                    raise
            self.close()
            self.open(*oargs, **okwargs)
        return retval

    @property
    def timeout(self) -> Optional[float]:
        """Connection timeout in seconds.

        Set timeout to 500 ms:

        >>> conn.timeout = 0.5

        .. note::
            The timeout can only be set while a connection is open.
        """
        if self.sock:
            return self.sock.gettimeout()
        return None

    @timeout.setter
    def timeout(self, secs: Optional[float]):
        if self.sock:
            self.sock.settimeout(secs)

    @property
    def tls(self) -> Optional[str]:     # type: ignore[override]
        """TLS version."""
        if isinstance(self.sock, ssl.SSLSocket):
            return self.sock.version()
        return None

    auth: tuple = ()
    """Positional arguments for SASL mechanism constructors."""

    capabilities: Optional[Capabilities] = None
    """Server capabilities."""

    file: Optional[IO] = None
    """File-like access to :attr:`sock`."""

    host: Optional[str] = None
    """Remote address."""

    kwauth: dict[str, Any] = {}
    """Keyword arguments for SASL mechanism constructors."""

    lock: threading.Lock
    """Operation lock."""

    logauth: bool
    """Log the authentication exchange?"""

    logger: logging.Logger = logging.getLogger(__name__)
    """Logger to use.

    :class:`SieveConn` logs messages of the following priorities:

    ========================  =====================================
    Priority                  Used for
    ========================  =====================================
    :const:`logging.ERROR`    Non-fatal errors
    :const:`logging.WARNING`  Security warnings
    :const:`logging.INFO`     State changes
    :const:`logging.DEBUG`    Data sent to/received from the server
    ========================  =====================================

    Suppress logging:

    >>> from logging import getLogger
    >>> getLogger('sievemgr').setLevel(logging.CRITICAL)

    Use an individual logger:

    >>> from logging import getLogger
    >>> conn.logger = getLogger('foo').addHandler(logging.NullHandler())

    Print data send to/received from the server to standard error:

    >>> from logging import getLogger
    >>> getLogger('sievemgr').setLevel(logging.DEBUG)
    >>> conn.execute('listscripts')
    C: LISTSCRIPTS
    S: "foo.sieve" ACTIVE
    S: "bar.sieve"
    S: "baz.sieve"
    S: OK "Listscripts completed"
    (Response(response='ok', code=(), data=(), message=None),
     [('foo.sieve', True), ('bar.sieve', False), ('baz.sieve', False)])
    """

    login: str = ''
    """Login name (authentication ID)."""

    ocsp: bool
    """Check whether the server certificate was revoked?"""

    owner: str = ''
    """User whose scripts are managed (authorisation ID)."""

    poll: Optional[select.poll] = None
    """Polling object for :attr:`sock`."""

    port: Optional[int] = None
    """Remote port."""

    sasl: Iterable[AuthMech] = ()
    """SASL mechanisms."""

    sock: Optional[socket.SocketType] = None
    """Underlying socket."""

    source: tuple[str, int] = ('', 0)
    """Source address and port."""

    sslcontext: ssl.SSLContext = ssl.create_default_context()
    """Settings for negotiating Transport Layer Security (TLS).

    Disable workarounds for broken X.509 certificates:

    >>> SieveConn.sslcontext.verify_flags |= ssl.VERIFY_X509_STRICT

    Load client certificate/key pair:

    >>> SieveConn.sslcontext.load_cert_chain(cert='cert.pem')

    Use a custom certificate authority:

    >>> SieveConn.sslcontext.load_verify_locations(cafile='ca.pem')
    """

    warning: Optional[str] = None
    """Warning issued in response to last "CHECKSCRIPT" or "PUTSCRIPT".

    For example:

    >>> with open('script.sieve', 'br') as file:
    >>>     conn.execute('putscript', file, file.name)
    (Response(response='ok', code=('warnings,'),
     data=('line 7: may need to be frobnicated',),
     message=None), [])
    >>> conn.warning
    'line 7: may need to be frobnicated'

    .. note::
        Only set by :meth:`collect`, :meth:`execute`,
        and :class:`SieveManager` methods.

    .. seealso::
        :RFC:`5804` (sec. 1.3)
            ManageSieve "WARNINGS" response code.
    """

    def _connect(self, host: str, port: int = 4190,
                 source: tuple[str, int] = ('', 0),
                 timeout: Optional[float] = socket.getdefaulttimeout()):
        """Connect to `host` at `port`."""
        self.sock = socket.create_connection((host, port), timeout=timeout,
                                             source_address=source)
        file = self.sock.makefile('rwb')
        self.file = LogIOWrapper.wrapfile(file, logger=self.logger)
        self.poll = select.poll()
        self.poll.register(self.sock)
        self.host = host
        self.port = port

    def _follow(self, url: str):
        """Close the current connection and :meth:`open` `url` instead."""
        try:
            ref = URL.fromstr(url)
        except ValueError as err:
            raise SieveProtocolError(err) from err
        self.logger.info('referred to %s', url)
        args, kwargs = self.getstate()
        del args[0]
        del kwargs['port']
        host = ref.hostname
        port = 4190 if ref.port is None else ref.port
        self.close()
        self.open(host, *args, port=port, **kwargs)


class SieveManager(SieveConn, contextlib.AbstractContextManager):
    """High-level interface to a ManageSieve connection.

    For example:

    >>> with SieveManager('imap.foo.example', 'user', 'password') as mgr:
    >>>     with open('sieve.script', 'br') as script:
    >>>         mgr.putscript(script, script.name)
    >>>     mgr.setactive('sieve.script')

    .. note::
        :class:`SieveManager` should *always* be used as a context manager.

    .. warning::
        :class:`SieveManager` is not thread-safe.
    """

    def __init__(self, *args, backups: int = 0,
                 memory: int = 524_288, **kwargs):
        """:meth:`Open <open>` a connection to a ManageSieve server.

        Arguments:
            backup: Default backup policy.
            memory: Maximum memory to allocate for temporary files in bytes.
                If files gets larger, they are written to the disk.
            args: Positional arguments for :meth:`SieveConn.open`.
            kwargs: Keyword arguments for :meth:`SieveConn.open`.
        """
        super().__init__(*args, **kwargs)
        self.backups: int = backups
        self.memory: int = memory

    # pylint: disable=redefined-outer-name
    def __exit__(self, _exctype, excvalue, _traceback):
        """Exit the context and close the connection appropriately."""
        if isinstance(excvalue, (ConnectionError, DNSError, TimeoutError)):
            self.close()
        elif isinstance(excvalue, ProtocolError):
            self.shutdown()
        else:
            try:
                self.logout()
            except ClientOperationError:
                try:
                    self.shutdown()
                except OSError:
                    self.close()

    def backupscript(self, script: str, keep: int = 1):
        """Make an Emacs-style backup of `script`.

        `keep` = 1
            :file:`script` is backed up as :file:`script~`.

        `keep` > 1
            :file:`script` is backed up as :file:`script.~{n}~`.
            `n` starts with 1 and increments with each backup.

        Arguments:
            script: Script name.
            keep: How many backups to keep.
        """
        def getfiles() -> Iterator[str]:
            for script, _ in self.listscripts():
                yield script
        def copy(src: str, targ: str):
            self.copyscript(src, targ, backups=0)
        backup(script, keep, getfiles, copy, self.deletescript)

    def checkscript(self, script: Union[str, IO]):
        """Check whether `script` is semantically valid.

        Arguments:
            script: Script (*not* script name).

        Raises:
            SieveCapabilityError: "CHECKSCRIPT" not supported.
            SieveOperationError: `script` is *not* semantically valid.

        .. important::
            Sieve scripts must be encoded in UTF-8.
        """
        assert self.capabilities
        if not self.capabilities.version:
            raise SieveCapabilityError('CHECKSCRIPT: not supported')
        self.execute('CHECKSCRIPT', script)

    def copyscript(self, source: str, target: str,
                   backups: Optional[int] = None):
        """Download `source` and re-upload it as `target`.

        Arguments:
            source: Source name.
            target: Target name.
            backups: Backup policy (default: :attr:`backups`).
        """
        with SpooledTemporaryFile(max_size=self.memory, mode='bw+') as temp:
            temp.write(self.getscript(source).encode('utf8'))
            temp.seek(0)
            self.putscript(temp, target, backups=backups)

    def deletescript(self, script: str):
        """Delete `script`."""
        self._scripts = None
        self.validname(script, check=True)
        self.execute('DELETESCRIPT', script)
        self.logger.info('removed %s', script)

    def editscripts(self, command: list[str], scripts: list[str], *args,
                    catch: Optional[Callable[[Exception, str], bool]] = None,
                    check: bool = True, create: bool = True,
                    **kwargs) -> subprocess.CompletedProcess:
        """Download `scripts`, edit them with `command`, and re-upload them.

        The `scripts` are appended to the `command`, which is then passed
        to :func:`subprocess.run`. Scripts that have been changed are then
        re-uploaded to the server. If the server has closed the connection
        in the meantime, the connection is re-established automatically.

        If :meth:`putscript` raises an error and `catch` has been given,
        then the error and the name of the offending script are passed to
        `catch`, which should return `True` if the `command` should be
        re-invoked for that script and `False` otherwise. Either way,
        the error will be suppressed.

        For example:

        >>> mgr.editscripts(['vi'], ['foo.sieve'])

        >>> cp = mgr.editscripts(['cmp'], ['a.sieve', 'b.sieve'], check=False)
        >>> if cp.returncode != 0:
        >>>     print('a.sieve and b.sieve differ')

        Arguments:
            command: Command to run.
            scripts: Scripts to edit.
            catch: Error handler.
            check: See :func:`subprocess.run`.
            create: Create scripts that do not exist?
            args: Positional arguments for :func:`subprocess.run`.
            kwargs: Keywords arguments for :func:`subprocess.run`.
        """
        for script in scripts:
            self.validname(script, check=True)
            if path.sep in script:
                raise ValueError(f'{script}: contains {path.sep}')
        with TemporaryDirectory() as tmpdir:
            fnames = []
            ctimes = []
            for script in scripts:
                fname = path.join(tmpdir, script)
                with open(fname, 'w', encoding='utf8') as file:
                    try:
                        file.write(self.getscript(script))
                    except SieveOperationError as err:
                        if not create:
                            raise
                        if not err.code:
                            if self.scriptexists(script):
                                raise
                        elif not err.matches('nonexistent'):
                            raise
                fnames.append(fname)
                ctimes.append(os.stat(fname).st_ctime)
            while True:
                cp = subprocess.run(command + fnames, *args,
                                    check=check, **kwargs)
                retry: list[tuple[str, str, float]] = []
                for script, fname, ctime in zip(scripts, fnames, ctimes):
                    if os.stat(fname).st_ctime > ctime:
                        with open(fname, 'rb') as file:
                            try:
                                self.withreopen(self.putscript, file, script)
                            except SieveOperationError as err:
                                if catch is None:
                                    raise
                                if catch(err, script):
                                    retry.append((script, fname, ctime))
                if not retry:
                    return cp
                scripts, fnames, ctimes = map(list, zip(*retry))
        # NOTREACHED

    def getactive(self) -> Optional[str]:
        """Get the name of the active script."""
        for name, active in self.listscripts():
            if active:
                return name
        return None

    def getscript(self, script: str) -> str:
        """Download `script`.

        For example:

        >>> with open('foo.sieve', 'w', encoding='utf8') as file:
        >>>     file.write(mgr.getscript(file.name))

        Arguments:
            script: Script name.
        """
        self.validname(script, check=True)
        try:
            # pylint: disable=unbalanced-tuple-unpacking
            _, ((content,),) = self.execute('GETSCRIPT', script)
        except ValueError as err:
            raise SieveProtocolError('unexpected data') from err
        if isinstance(content, ACAPAtom):
            raise SieveProtocolError('unexpected atom')
        if isinstance(content, str):
            return content
        raise SieveProtocolError('unexpected ' + type(content).__name__)

    def havespace(self, script: str, size: int):
        """Check whether there is enough space for `script`.

        Arguments:
            script: Script name.
            size: Script size in bytes.

        Raises:
            SieveOperationError: *Not* enough space.
        """
        self.validname(script, check=True)
        self.execute('HAVESPACE', script, size)

    def listscripts(self, cached: bool = False) -> list[tuple[str, bool]]:
        """List scripts and whether they are the active script.

        For example:

        >>> mgr.listscripts()
        [('foo.sieve', False), ('bar.sieve', True)]

        >>> scripts = [script for script, _ in mgr.listscripts()]

        Arguments:
            cached: Return cached response? [#cache]_

        Returns:
            A list of script name/status tuples.

        .. [#cache] The cache is updated whenever a
                    state-changing operation is requested.
        """
        if not cached or self._scripts is None:
            self._scripts = []
            for line in self.execute('LISTSCRIPTS')[1]:
                try:
                    name = line[0]
                except IndexError as err:
                    raise SieveProtocolError('no data') from err
                if not isinstance(name, str):
                    raise SieveProtocolError('expected string')
                try:
                    status = line[1]
                    if not isinstance(status, str):
                        raise SieveProtocolError('expected string')
                    active = status.casefold() == 'active'
                except IndexError:
                    active = False
                self._scripts.append((name, active))
        return self._scripts

    def logout(self):
        """Log out.

        .. note::
            :meth:`logout` should be called to close the connection
            unless :class:`SieveManager` is used as a context manager.

        .. warning::
            Logging out is unsafe after a :exc:`ProtocolError`.
            Use :meth:`shutdown` instead.
        """
        if self.sock is not None:
            try:
                self.execute('LOGOUT')
                self.logger.info('logged out')
            except (OperationError, ProtocolError):
                try:
                    self.shutdown()
                except OSError:
                    pass
            except ConnectionError:
                pass
        self.close()

    def noop(self, tag: Optional[str] = None) -> Optional[str]:
        """Request a no-op.

        For example:

        >>> mgr.noop('foo')
        'foo'

        Arguments:
            tag: String for the server to echo back.

        Returns:
            Server echo.

        Raises:
            SieveCapabilityError: "NOOP" not supported.
            SieveProtocolError: Server violated the ManageSieve protocol.
        """
        assert self.capabilities
        if not self.capabilities.version:
            raise SieveCapabilityError('NOOP: not supported')
        args = () if tag is None else (tag,)
        res, _ = self.execute('NOOP', *args)
        try:
            data = res.code[1]
        except IndexError:
            return None
        if isinstance(data, ACAPAtom) or not isinstance(data, str):
            raise SieveProtocolError('expected string')
        return data

    def putscript(self, source: Union[str, IO], target: str,
                  backups: Optional[int] = None):
        """Upload `source` to the server as `target`.

        For example:

        >>> mgr.putscript('# empty', 'foo.sieve')

        >>> with open('foo.sieve', 'br') as file:
        >>>     mgr.putscript(file, 'foo.sieve')

        Arguments:
            source: Script.
            target: Script name.
            backups: Backup policy (default: :attr:`backups`).

        .. important::
            Sieve scripts must be encoded in UTF-8.
        """
        self.validname(target, check=True)
        try:
            keep = self.backups if backups is None else backups
            self.backupscript(target, keep=keep)
        except SieveOperationError as err:
            if not err.code:
                for script, _ in self.listscripts():
                    if script == target:
                        raise
            elif not err.matches('nonexistent'):
                raise
        self._scripts = None
        self.execute('PUTSCRIPT', target, source)
        self.logger.info('uploaded %s', target)

    def renamescript(self, source: str, target: str, emulate: bool = True):
        """Rename `source` to `target`.

        Some servers do not the support the "RENAMESCRIPT" command.
        On such servers, renaming may be emulated by downloading `source`,
        re-uploading it as `target`, setting `target` to the active script
        if `source` is the active script, and then deleting `source`.

        For example:

        >>> mgr.renamescript('foo.sieve', 'bar.sieve', emulate=False)

        Arguments:
            source: Script name.
            target: Script name.
            emulate: Emulate "RENAMESCRIPT" if the server does not support it?

        Raises:
            SieveCapabilityError: "RENAMESCRIPT" not supported. [#emulate]_
            SieveOperationError: `source` does not exist or `target` exists.

        .. [#emulate] Only raised if `emulate` is `False`.
        """
        assert self.capabilities
        self.validname(source, check=True)
        self.validname(target, check=True)
        if self.capabilities.version:
            self._scripts = None
            self.execute('RENAMESCRIPT', source, target)
            self.logger.info('renamed %s to %s', source, target)
        elif emulate:
            sourceactive: Optional[bool] = None
            for script, active in self.listscripts():
                if script == source:
                    sourceactive = active
                if script == target:
                    raise SieveOperationError(
                        code=(ACAPAtom('alreadyexists'),),
                        message=f'{target}: {os.strerror(EEXIST)}'
                    )
            if sourceactive is None:
                raise SieveOperationError(
                    code=(ACAPAtom('nonexistent'),),
                    message=f'{source}: {os.strerror(ENOENT)}'
                )
            self.copyscript(source, target, backups=0)
            if sourceactive:
                self.setactive(target)
            self.deletescript(source)
        else:
            raise SieveCapabilityError('RENAMESCRIPT: not supported')

    def scriptexists(self, script: str, cached: bool = False) -> bool:
        """Check if `script` exists.

        Arguments:
            script: Script name.
            cached: Return cached response? [#cache]_
        """
        self.validname(script, check=True)
        return any(s == script for s, _ in self.listscripts(cached=cached))

    def setactive(self, script: str):
        """Mark `script` as the active script."""
        self.validname(script, check=True)
        self._scripts = None
        self.execute('SETACTIVE', script)
        self.logger.info('activated %s', script)

    def unauthenticate(self):
        """Unauthenticate.

        Raises:
            SieveCapabilityError: "UNAUTHENTICATE" not supported.
        """
        assert self.capabilities
        if not self.capabilities.unauthenticate:
            raise SieveCapabilityError('UNAUTHENTICATE: not supported')
        self.execute('UNAUTHENTICATE')
        self.login = ''
        self.owner = ''
        self.logger.info('un-authenticated')

    def unsetactive(self):
        """Deactivate the active script."""
        self._scripts = None
        self.execute('SETACTIVE', '')
        self.logger.info('deactivated active script')

    @classmethod
    def validname(cls, script: str, check: bool = False) -> bool:
        """Check whether `script` is a valid script name.

        Raises:
            ValueError: `script` is *not* valid and `check` is `True`.
        """
        if cls._matchname(script):
            return True
        if check:
            raise ValueError(ctrlescape(script) + ': bad name')
        return False

    backups: int = 0
    """Number of backups to keep."""

    _matchname: Callable[..., Optional[re.Match]] = re.compile(
            '[^\u0000-\u001f\u0080-\u009f\u2028\u2029]+'
    ).fullmatch
    """Check whether a string is a valid script name.

    .. seealso::
        :RFC:`5198` (sec. 2)
            Definition of unicode format for network interchange.
        :RFC:`5804` (sec. 1.6)
            ManageSieve script names.
    """

    _scripts: Optional[list[tuple[str, bool]]] = None
    """Scripts returned by the last :meth:`listscripts`."""


class SieveSASLAdapter(AbstractSASLAdapter):
    """Adapter to send SASL messages over a :class:`SieveConn`."""

    def __init__(self, connection: 'SieveConn'):
        """Initialise the adapter."""
        self.conn = connection

    def abort(self):
        self.send(b'*')
        self.end()

    def begin(self, name: str, data: Optional[bytes] = None):
        assert self.conn
        args: list[Any] = [ACAPAtom('AUTHENTICATE'), name.upper()]
        if data is not None:
            args.append(b64encode(data))
        self.conn.sendline(*args)   # type: ignore[arg-type]

    def end(self):
        assert self.conn
        res = Response.fromline(self.conn.receiveline())
        if res.response != 'ok':
            raise res.toerror()

    def send(self, data: bytes):
        assert self.conn
        conn = self.conn
        conn.sendline(b64encode(data))  # type: ignore[arg-type]

    def receive(self) -> bytes:
        assert self.conn
        line = self.conn.receiveline()
        if isinstance(line[0], ACAPAtom):
            res = Response.fromline(line)
            raise res.toerror()
        try:
            word, = line
        except ValueError as err:
            raise SieveProtocolError('unexpected data') from err
        if isinstance(word, ACAPAtom) or not isinstance(word, str):
            raise SieveProtocolError('expected string')
        return b64decode(word)

    @property
    def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
        assert self.conn
        assert self.conn.sock
        return self.conn.sock

    @sock.setter
    def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
        assert self.conn
        self.conn.sock = sock

    conn: Optional[SieveConn] = None
    """Underlying connection."""


CapabilitiesT = TypeVar('CapabilitiesT', bound='Capabilities')
""":class:`Capabilities` type variable."""


@dataclass
class Capabilities():
    """Server capabilities."""

    @classmethod
    def fromlines(cls: type[CapabilitiesT], lines: Iterable[ACAPLine]) \
            -> CapabilitiesT:
        """Create a :class:`Capabilities` object from lines."""
        obj = cls()
        for words in lines:
            try:
                key = words[0].casefold()   # type: ignore[union-attr]
            except IndexError as err:
                raise SieveProtocolError('expected word') from err
            except AttributeError as err:
                raise SieveProtocolError('expected string') from err
            try:
                value, = words[1:]
            except ValueError:
                value = ''
            if not isinstance(value, str):
                raise SieveProtocolError('expected string')
            if key in ('implementation', 'language', 'owner', 'version'):
                setattr(obj, key, value)
            elif key in ('notify', 'sasl', 'sieve'):
                setattr(obj, key, value.casefold().split())
            elif key in ('starttls', 'unauthenticate'):
                setattr(obj, key, True)
            elif key == 'maxredirects':
                setattr(obj, key, int(value))
            else:
                obj.notunderstood[key] = value
        return obj

    implementation: Optional[str] = None
    """Server application."""

    sieve: tuple[str, ...] = ()
    """Supported Sieve modules."""

    language: Optional[str] = None
    """Language."""

    maxredirects: Optional[int] = None
    """Maximum number of redirect operations permitted in a script."""

    notify: tuple[str, ...] = ()
    """URI schema parts for supported notification methods."""

    owner: str = ''
    """Canonical name of the user authorised as."""

    sasl: tuple[str, ...] = ()
    """Supported authentication methods."""

    starttls: bool = False
    """Is "STARTTLS" available?"""

    unauthenticate: bool = False
    """Is "UNAUTHENTICATE" available?"""

    version: Optional[str] = None
    """ManageSieve protocol version."""

    notunderstood: dict = dataclasses.field(default_factory=dict)
    """Capabilities not understood by SieveManager."""


ResponseT = TypeVar('ResponseT', bound='Response')
"""Type variable for :class:`Response`."""


@dataclass(frozen=True)
class Response():
    """Server response to a command.

    .. seealso::
        :RFC:`5804` (secs. 1.2, 1.3, 4, 6.4, and passim)
            ManageSieve responses
    """

    @classmethod
    def fromline(cls: type[ResponseT], line: ACAPLine) -> ResponseT:
        """Create a :class:`Response` object from an :class:`ACAPLine`."""
        # pylint: disable=redefined-outer-name
        code = cls.code
        message = cls.message
        for word in line:
            if word is None:
                raise SieveProtocolError('expected atom, list, or string')
            if isinstance(word, ACAPAtom):
                response = word.casefold()
            elif isinstance(word, list):
                code = tuple(word)
            elif isinstance(word, str):
                message = word
            else:
                raise SieveProtocolError('expected atom, list, or string')
        return cls(response=response, code=code, message=message)

    def __str__(self) -> str:
        """:attr:`self.message` or a stub message."""
        return self.message if self.message else f'server says {self.response}'

    def matches(self, *categories: str) -> bool:
        """Check if :attr:`code[0]` matches one of the given `categories`.

        For example:

        >>> with open('script.sieve') as script:
        >>>     try:
        >>>         mgr.putscript(script, script.name)
        >>>     except SieveOperationError as err:
        >>>         if err.matches('quota'):
        >>>             print('over quota')

        Print more informative messages:

        >>> with open('script.sieve') as script:
        >>>     try:
        >>>         mgr.putscript(script, script.name)
        >>>     except SieveOperationError as err:
        >>>         if err.matches('quota/maxscripts'):
        >>>             print('too many scripts')
        >>>         elif err.matches('quota/maxsize'):
        >>>             print(f'{script.name} is too large')
        >>>         elif err.matches('quota'):
        >>>             print('over quota')
        """
        try:
            rescode = self.code[0]
        except IndexError:
            return False
        assert isinstance(rescode, str)
        for cat in categories:
            pattern = re.escape(cat) + r'(/|$)'
            if re.match(pattern, rescode, flags=re.IGNORECASE):
                return True
        return False

    def toerror(self) -> 'SieveError':
        """Convert `Response` into an error."""
        cls = (SieveConnectionError if self.response == 'bye' else
               SieveOperationError)
        return cls(self.response, self.code, self.message)

    response: str
    """``'ok'``, ``'no'``, or ``'bye'``.

    =========  ===========================
    Response   Meaning
    =========  ===========================
    ``'ok'``   Success
    ``'no'``   Failure
    ``'bye'``  Connection closed by server
    =========  ===========================
    """

    code: tuple[ACAPWord, ...] = ()
    """Response code.

    ManageSieve response codes are lists of categories, separated by
    slashes ("/"), where each category is the super-category of the
    next (e.g., "quota/maxsize").

    Some response codes carry data (e.g., ``TAG "SYNC-123"``).

    See :RFC:`5804` (sec. 1.3) for a list of response codes.

    .. warning::
        Servers need *not* return response codes.
    """

    message: Optional[str] = None
    """Human-readable message.

    .. warning::
        Servers need *not* return a message.

    .. note::
        Prefer :meth:`Response.__str__`.
    """


@dataclass(frozen=True)
class SRV():
    """DNS `SRV record`_."""

    priority: int
    weight: int
    host: str
    port: int


URLT = TypeVar('URLT', bound='URL')
"""Type variable for :class:`URL`."""


@dataclass(frozen=True)
class URL():
    """Sieve URL.

    .. seealso::
        :RFC:`5804` (sec. 3)
            Sieve URL Scheme
    """

    @classmethod
    def fromstr(cls: type[URLT], url: str) -> URLT:
        """Create a :class:`URL` object from a URL string.

        Raises:
            ValueError: Not a valid Sieve URL.
        """
        if not re.match(r'([a-z][a-z0-9\+\-\.]*:)?//', url):
            url = 'sieve://' + url
        parts = urllib.parse.urlsplit(url)
        if parts.query or parts.fragment:
            raise ValueError(f'{url}: not a Sieve URL')
        if not parts.hostname:
            raise ValueError(f'{url}: no host')
        try:
            owner, scriptname = parts.path.split('/', maxsplit=2)[1:]
        except ValueError:
            owner, scriptname = parts.path[1:], None
        return cls(
            scheme=parts.scheme,
            username=parts.username,
            password=parts.password,
            hostname=parts.hostname,
            port=parts.port,
            owner=owner,
            scriptname=scriptname
        )

    def __str__(self):
        """Convert into a URL string."""
        url = ''
        if self.scheme:
            url += f'{self.scheme}://'
        else:
            url += 'sieve://'
        if self.username:
            url += self.username
            if self.password is not None:
                url += f':{self.password}'
            url += '@'
        if self.hostname:
            url += self.hostname
        else:
            url += 'localhost'
        if self.port:
            url += f':{self.port}'
        if self.owner:
            url += f'/{self.owner}'
        if self.scriptname:
            url += f'/{self.scriptname}'
        return url

    hostname: str
    scheme: Optional[str] = None
    username: Optional[str] = None
    password: Optional[str] = None
    port: Optional[int] = None
    owner: Optional[str] = None
    scriptname: Optional[str] = None


#
# Authentication
#

BaseAuthT = TypeVar('BaseAuthT', bound='BaseAuth')
"""Type variable for :class:`BaseAuth`."""


class BaseAuth(AbstractAuth, ABC):
    """Base class for authentication mechanisms.

    .. note::
        :class:`BaseAuth` handles SASL compression transparently.
        Most mechanisms should be implemented by overriding
        :meth:`__init__` and :meth:`exchange` and using
        :meth:`send` and :meth:`receive`.
    """

    @staticmethod
    # pylint: disable=redefined-outer-name
    def prepare(string: str) -> str:
        """Prepare a string according to the SASLprep profile.

        Raises:
            ValueError: String contains bad characters.

        .. seealso::
            :RFC:`3454`
                Stringprep
            :RFC:`4013`
                SASLPrep
        """
        if any(rlcat := list(map(stringprep.in_table_d1, string))):
            if not rlcat[0]:
                raise ValueError(f'{string}:1: not in RandLCat')
            if not rlcat[-1]:
                raise ValueError(f'{string}:{len(string)}: not in RandLCat')
            if any(map(stringprep.in_table_d2, string)):
                raise ValueError(f'{string}: mixes RandLCat and LCat')
        prep = ''
        for i, char in enumerate(string, start=1):
            if stringprep.in_table_b1(char):
                pass
            elif stringprep.in_table_c12(char):
                prep += ' '
            elif stringprep.in_table_c21_c22(char):
                raise ValueError(f'{string}:{i}: control character')
            elif stringprep.in_table_c3(char):
                raise ValueError(f'{string}:{i}: private use character')
            elif stringprep.in_table_c4(char):
                raise ValueError(f'{string}:{i}: non-character code point')
            elif stringprep.in_table_c5(char):
                raise ValueError(f'{string}:{i}: surrogate code point')
            elif stringprep.in_table_c6(char):
                raise ValueError(f'{string}:{i}: not plain text')
            elif stringprep.in_table_c7(char):
                raise ValueError(f'{string}:{i}: not canonical')
            elif stringprep.in_table_c8(char):
                raise ValueError(f'{string}:{i}: changes display/deprecated')
            elif stringprep.in_table_c9(char):
                raise ValueError(f'{string}:{i}: tagging character')
            elif stringprep.in_table_a1(char):
                raise ValueError(f'{string}:{i}: unassigned code point')
            else:
                prep += char
        return unicodedata.ucd_3_2_0.normalize('NFKC', prep)

    @classmethod
    def getmechs(cls: type[BaseAuthT], sort: bool = True,
                 obsolete: bool = False) -> Iterable[type[BaseAuthT]]:
        """Get authentication mechanisms.

        Arguments:
            sort: Sort mechanisms by :attr:`order`?
            obsolete: Return obsolete mechanisms, too?
        """
        mechs: set[type[BaseAuthT]] = set()
        for subcls in cls.__subclasses__():
            # pylint: disable=bad-indentation
            if (not subcls.__abstractmethods__
                and (obsolete or not subcls.obsolete)):
                    mechs.add(subcls)
            mechs.update(subcls.getmechs(sort=False, obsolete=obsolete))
        if sort:
            return list(sorted(mechs, key=lambda c: c.order))
        return mechs

    def __init__(self, adapter: AbstractSASLAdapter,
                 authcid: str, authzid: str = '',
                 prepare: SASLPrep = SASLPrep.ALL):
        """Prepare authentication.

        Arguments:
            authcid: Authentication ID (user to login as).
            authzid: Authorisation ID (use whose rights to acquire).
            prepare: Which credentials to :meth:`prepare`.

        Raises:
            ValueError: Bad characters in username.
        """
        prepare &= SASLPrep.USERNAMES   # type: ignore[assignment]
        self.adapter = adapter
        self.authcid = self.prepare(authcid) if prepare else authcid
        self.authzid = self.prepare(authzid) if prepare else authzid

    # pylint: disable=useless-return
    def __call__(self) -> Optional[Capabilities]:
        """Perform authentication.

        .. note::
            Calls :meth:`exchange` and :meth:`end`.
        """
        self.exchange()
        if self.state == AuthState.RECEIVED:
            self.send(b'')
        self.end()
        return None

    def abort(self):
        """Abort authentication."""
        self.adapter.abort()
        self.state = AuthState.DONE

    def begin(self, data: Optional[bytes] = None):
        """Begin authentication.

        Arguments:
            data: Optional client-first message.
        """
        if self.state == AuthState.PREAUTH:
            self.adapter.begin(self.name, data)
            self.state = AuthState.SENT
        else:
            raise SASLProtocolError(f'SASL state {self.state}: unexpected')

    def end(self):
        """Conclude authentication."""
        if self.state == AuthState.SENT:
            self.adapter.end()
        else:
            raise SASLProtocolError(f'SASL state {self.state}: unexpected')

    @abstractmethod
    def exchange(self):
        """Exchange SASL messages."""

    def send(self, data: bytes):
        """Encode and send an SASL message.

        .. note::
            Calls :meth:`begin` if needed.
        """
        if self.state == AuthState.PREAUTH:
            self.begin(data)
        elif self.state == AuthState.RECEIVED:
            self.adapter.send(data)
            self.state = AuthState.SENT
        else:
            raise SASLProtocolError(f'SASL state {self.state}: unexpected')

    def receive(self) -> bytes:
        """Receive and decode an SASL message.

        .. note::
            Calls :meth:`begin` if needed.
        """
        if self.state == AuthState.PREAUTH:
            self.begin()
        if self.state == AuthState.SENT:
            try:
                data = self.adapter.receive()
                self.state = AuthState.RECEIVED
            except SieveOperationError as err:
                if err.response != 'ok':
                    raise
                if not err.matches('sasl'):
                    # pylint: disable=raise-missing-from
                    raise SASLProtocolError('expected data')
                try:
                    word = err.code[1]
                except ValueError as valuerr:
                    raise SASLProtocolError('unexpected data') from valuerr
                if isinstance(word, ACAPAtom) or not isinstance(word, str):
                    # pylint: disable=raise-missing-from
                    raise SASLProtocolError('expected string')
                data = b64decode(word)
                self.state = AuthState.DONE
            return data
        raise SASLProtocolError(f'SASL state {self.state}: unexpected')

    @property
    def sock(self) -> Union[socket.SocketType, ssl.SSLSocket]:
        """Underlying socket."""
        assert self.adapter
        return self.adapter.sock

    @sock.setter
    def sock(self, sock: Union[socket.SocketType, ssl.SSLSocket]):
        assert self.adapter
        self.adapter.sock = sock

    adapter: AbstractSASLAdapter
    """Underlying SASL adapter."""

    obsolete: bool = False
    """Is this mechanism obsolete?"""

    order: int = 0
    """Mechanism precedence."""

    state: AuthState = AuthState.PREAUTH
    """Current authentication state."""


class BasePassAuth(BaseAuth, ABC):
    """Base class for password-based authentication mechanisms."""

    def __init__(self, connection: AbstractSASLAdapter,
                 authcid: str, password: str, authzid: str = '',
                 prepare: SASLPrep = SASLPrep.ALL):
        """Prepare authentication.

        Arguments:
            authcid: Authentication ID (user to login as).
            authzid: Authorisation ID (use whose rights to acquire).
            password: Password.
            prepare: Which credentials to :meth:`BaseAuth.prepare`.

        Raises:
            ValueError: Bad characters in username or password.
        """
        super().__init__(connection, authcid, authzid, prepare)
        prepare &= SASLPrep.PASSWORDS   # type: ignore[assignment]
        self.password = self.prepare(password) if prepare else password

    password: str
    """Password."""


class BaseScramAuth(BasePassAuth, ABC):
    """Base class for SCRAM authentication mechanisms.

    .. seealso::
        :RFC:`5802`
            Salted Challenge Response Authentication Mechanism (SCRAM).
        :RFC:`7677`
            SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
        https://datatracker.ietf.org/doc/html/draft-melnikov-scram-bis
            Updated recommendations for implementing SCRAM.
        https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha-512-03
            SCRAM-SHA-512 and SCRAM-SHA-512-PLUS.
        https://datatracker.ietf.org/doc/html/draft-melnikov-scram-sha3-512-03
            SCRAM-SHA3-512 and SCRAM-SHA3-512-PLUS.
        https://csb.stevekerrison.com/post/2022-01-channel-binding
            Discussion of TLS channel binding.
        https://csb.stevekerrison.com/post/2022-05-scram-detail
            Discussion of SCRAM.
    """

    def exchange(self):
        # Compare to
        # * https://github.com/stevekerrison/auth-examples
        # * https://github.com/horazont/aiosasl

        def todict(msg: bytes) -> dict[bytes, bytes]:
            return dict([a.split(b'=', maxsplit=1) for a in msg.split(b',')])

        def escape(b: bytes) -> bytes:
            return b.replace(b'=', b'=3D').replace(b',', b'=2C')

        # Parameters
        authcid = self.authcid.encode('utf8')
        authzid = self.authzid.encode('utf8')
        password = self.password.encode('utf8')
        chan_bind_type = self.cbtype.encode('utf8')
        chan_bind_data = self.cbdata
        c_nonce_len = self.noncelen
        digest = self.digest

        # Send client-first message
        chan_bind_attr = b'p=%s' % chan_bind_type if chan_bind_type else b'n'
        c_first_prefix = chan_bind_attr + b',' + escape(authzid)
        c_nonce = b64encode(secrets.token_bytes(c_nonce_len))
        c_first_bare = b'n=%s,r=%s' % (escape(authcid), c_nonce)
        c_first = c_first_prefix + b',' + c_first_bare
        self.send(c_first)

        # Receive server-first message
        try:
            s_first = self.receive()
        except SieveOperationError as err:
            # pylint: disable=bad-exception-cause (???)
            raise SASLCapabilityError(f'{self.name}: {err}') from err
        s_first_dict = todict(s_first)
        iters = int(s_first_dict[b'i'])
        s_nonce = s_first_dict[b'r']
        salt = b64decode(s_first_dict[b's'])

        # Send client-final message
        salted_pwd = hashlib.pbkdf2_hmac(digest, password, salt, iters)
        c_key = hmac.digest(salted_pwd, b'Client Key', digest)
        stored_key = hashlib.new(digest, c_key).digest()
        chan_bind = b64encode(c_first_prefix + b',' + chan_bind_data)
        c_final_prefix = b'c=%s,r=%s' % (chan_bind, s_nonce)
        auth_message = b','.join((c_first_bare, s_first, c_final_prefix))
        c_signature = hmac.digest(stored_key, auth_message, digest)
        c_proof = b64encode(bytes(a ^ b for a, b in zip(c_key, c_signature)))
        c_final = c_final_prefix + b',p=%s' % c_proof
        self.send(c_final)

        # Receive server-final message
        s_final = self.receive()
        s_key = hmac.digest(salted_pwd, b'Server Key', digest)
        s_signature = hmac.digest(s_key, auth_message, digest)
        s_final_dict = todict(s_final)
        if s_signature != b64decode(s_final_dict[b'v']):
            host = self.sock.getpeername()
            raise SASLSecurityError(f'{host}: verification failed')

    @property
    @abstractmethod
    def digest(self) -> str:
        """Digest name as used by :mod:`hashlib` and :mod:`hmac`."""

    cbtype: str = ''
    """TLS channel-binding type."""

    cbdata: bytes = b''
    """TLS channel-binding data."""

    noncelen: int = 18
    """Client nonce length in bytes."""


class BaseScramPlusAuth(BaseScramAuth, ABC):
    """Base class for SCRAM mechanisms with channel binding."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not isinstance(self.sock, ssl.SSLSocket):
            raise SASLProtocolError('non-TLS channel cannot be bound')
        for cbtype in ('tls-exporter', 'tls-unique', 'tls-server-endpoint'):
            if cbtype in ssl.CHANNEL_BINDING_TYPES:
                if cbdata := self.sock.get_channel_binding(cbtype):
                    self.cbtype = cbtype
                    self.cbdata = cbdata
                    break
        else:
            raise TLSCapabilityError('no supported channel-binding type')


class AuthzUnsupportedMixin():
    """Mixin for SASL mechanisms that do not support authorisation."""

    def __init__(self, *args, **kwargs):
        """Initialise the SASL mechanism.

        Raises:
            SASLCapabilityError: :attr:`authzid` is set."""
        assert isinstance(self, BaseAuth)
        super().__init__(*args, **kwargs)
        if self.authzid:
            raise SASLCapabilityError(f'{self.name}: no authorisation')


class CramMD5Auth(AuthzUnsupportedMixin, BasePassAuth):
    """CRAM-MD5 authentication.

    .. seealso::
        :RFC:`2195` (sec. 2)
            Definition of CRAM-MD5.
    """

    def exchange(self):
        challenge = self.receive()
        password = self.password.encode('utf8')
        digest = hmac.new(password, challenge, hashlib.md5)
        data = ' '.join((self.authcid, digest.hexdigest()))
        self.send(data.encode('utf8'))

    obsolete = True
    name = 'CRAM-MD5'


class ExternalAuth(BaseAuth):
    """EXTERNAL authentication.

    .. seealso::
        :RFC:`4422` (App. A)
            Definition of the EXTERNAL mechanism.
    """

    def __call__(self):
        """Authenticate."""
        args = (self.authzid.encode('utf8'),) if self.authzid else ()
        self.begin(*args)
        self.receive()
        self.send(b'')
        self.end()

    def exchange(self):
        """No-op."""

    name = 'EXTERNAL'


class LoginAuth(AuthzUnsupportedMixin, BasePassAuth):
    """LOGIN authentication.

    .. seealso::
        https://datatracker.ietf.org/doc/draft-murchison-sasl-login
            Definition of the LOGIN mechanism.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if {self.password} & {'\r', '\n', '\0'}:
            raise ValueError('password: contains CR, LF, or NUL')

    def exchange(self):
        self.receive()
        self.send(self.authcid.encode('utf8'))
        self.receive()
        self.send(self.password.encode('utf8'))

    obsolete = True
    name = 'LOGIN'


class PlainAuth(BasePassAuth):
    """PLAIN authentication.

    .. seealso::
        :RFC:`4616`
            PLAIN authentication mechanism.
    """

    def exchange(self):
        data = '\0'.join((self.authzid, self.authcid, self.password))
        self.send(data.encode('utf8'))

    name = 'PLAIN'


class ScramSHA1Auth(BaseScramAuth):
    """SCRAM-SHA-1 authentication."""
    digest = 'sha1'
    name = 'SCRAM-SHA-1'
    order = -10


class ScramSHA1PlusAuth(BaseScramPlusAuth, ScramSHA1Auth):
    """SCRAM-SHA-1-PLUS authentication."""
    name = 'SCRAM-SHA-1-PLUS'
    order = -1000


class ScramSHA224Auth(BaseScramAuth):
    """SCRAM-SHA-224 authentication."""
    digest = 'sha224'
    name = 'SCRAM-SHA-224'
    order = -20


class ScramSHA224PlusAuth(BaseScramPlusAuth, ScramSHA224Auth):
    """SCRAM-SHA-224-PLUS authentication."""
    name = 'SCRAM-SHA-224-PLUS'
    order = -2000


class ScramSHA256Auth(BaseScramAuth):
    """SCRAM-SHA-256 authentication."""
    digest = 'sha256'
    name = 'SCRAM-SHA-256'
    order = -30


class ScramSHA256PlusAuth(BaseScramPlusAuth, ScramSHA256Auth):
    """SCRAM-SHA-256-PLUS authentication."""
    name = 'SCRAM-SHA-256-PLUS'
    order = -3000


class ScramSHA384Auth(BaseScramAuth):
    """SCRAM-SHA-384 authentication."""
    digest = 'sha384'
    name = 'SCRAM-SHA-384'
    order = -40


class ScramSHA384PlusAuth(BaseScramPlusAuth, ScramSHA384Auth):
    """SCRAM-SHA-384-PLUS authentication."""
    name = 'SCRAM-SHA-384-PLUS'
    order = -4000


class ScramSHA512Auth(BaseScramAuth):
    """SCRAM-SHA-512 authentication."""
    digest = 'sha512'
    name = 'SCRAM-SHA-512'
    order = -50


class ScramSHA512PlusAuth(BaseScramPlusAuth, ScramSHA512Auth):
    """SCRAM-SHA-512-PLUS authentication."""
    name = 'SCRAM-SHA-512-PLUS'
    order = -5000


# pylint: disable=invalid-name
class ScramSHA3_512Auth(BaseScramAuth):
    """SCRAM-SHA-512 authentication."""
    digest = 'sha3_512'
    name = 'SCRAM-SHA3-512'
    order = -60


# pylint: disable=invalid-name
class ScramSHA3_512PlusAuth(BaseScramPlusAuth, ScramSHA3_512Auth):
    """SCRAM-SHA-512-PLUS authentication."""
    name = 'SCRAM-SHA3-512-PLUS'
    order = -6000


#
# SieveManager Shell
#

class BaseShell():
    """Base class for shells."""

    def __init__(self, clobber: bool = True):
        """Initialise a :class:`BaseShell` object."""
        self._completions: list[str] = []
        self.clobber = clobber
        self.commands = tuple(a[3:] for a in dir(self) if a.startswith('do_'))

    @staticmethod
    def getusage(func: Callable) -> Optional[str]:
        """Derive a usage message from a callable's docstring."""
        if docstring := func.__doc__:
            usage, _ = docstring.split('-', maxsplit=1)
            return 'usage: ' + usage.strip()
        return None

    def complete(self, text, n) -> Optional[str]:
        """Return the `n`-th completion for `text`.

        Tab-completion for commands is built-in, but tab-completion for
        arguments must be enabled by provided a :samp:`complete_{command}`
        method for each `command`. That command must return a list of
        :class:`str`-:class:`bool` pairs, where the boolean value
        controls whether a space is appended to the word.
        """
        if n == 0:
            logger = self.logger
            loglevel = logger.level
            logger.setLevel(logging.ERROR)
            try:
                begin = readline.get_begidx()
                buffer = readline.get_line_buffer()
                args = self.split(buffer[:begin])
                if (argidx := len(args)) == 0:
                    words = [c + ' ' for c in self.commands]
                else:
                    command = 'help' if args[0] == '?' else args[0]
                    try:
                        complete = getattr(self, f'complete_{command}')
                    except AttributeError:
                        return None
                    words = [word + ' ' if addspace else word
                             for word, addspace in complete(argidx, text)]
                self._completions = sorted(fnmatch.filter(words, text + '*'))
            finally:
                logger.setLevel(loglevel)
        try:
            return self._completions[n]
        except (AttributeError, IndexError):
            return None

    def confirm(self, prompt: str, default: ConfirmEnum = ConfirmEnum.NO,
                multi: bool = False) -> ConfirmEnum:
        """Prompt for confirmation.

        Arguments:
            prompt: Prompt.
            default: Choice to return if no answer is given.
            multi: Give choices "all" and "none"?
        """
        with TermIO() as tty:
            while True:
                print(prompt, end=' ', file=tty, flush=True)
                answer = tty.readline().strip().casefold()
                if answer == '':
                    return default
                if answer in ('y', 'yes'):
                    return ConfirmEnum.YES
                if answer in ('n', 'no'):
                    return ConfirmEnum.NO
                if multi:
                    if answer == 'all':
                        return ConfirmEnum.ALL
                    if answer == 'none':
                        return ConfirmEnum.NONE
                if multi:
                    print('Enter "yes", "no", "all", or "none"', file=tty)
                else:
                    print('Enter "yes" or "no"', file=tty)
            # NOTREACHED

    def enter(self):
        """Start reading commands from standard input.

        The shell is exited if the end-of-filer marker is
        reached or a command raises :exc:`StopIteration`.

        Commands must be provided by :samp:`do_{command}` methods.
        "?" is used as alias for "help", "!" as alias for "shell".

        .. note::
            Entering the shell binds :kbd:`tab` to :func:`rl_complete`
            if :mod:`readline` is implemented by `libedit`.
        """
        logger = self.logger
        if self.hasatty:
            oldcompleter = readline.get_completer()
            readline.set_auto_history(True)
            readline.set_completer(self.complete)
            readline.parse_and_bind('tab: complete')
            if 'libedit' in readline.__doc__:
                readline.parse_and_bind('python:bind ^I rl_complete')
            logger.info('enter "? [command]" for help and "exit" to exit')
        try:
            while True:
                try:
                    prompt = self.getprompt() if self.hasatty else None
                    line = input(prompt).strip()
                except EOFError:
                    break
                try:
                    self.executeline(line)
                except StopIteration:
                    break
                except (ConnectionError, DNSError, ProtocolError):
                    raise
                # pylint: disable=broad-exception-caught
                except Exception as err:
                    if not self.hasatty:
                        raise
                    if isinstance(err, subprocess.CalledProcessError):
                        logger.error('%s: exited with status %d',
                                     err.cmd[0], err.returncode)
                        self.retval = 1
                    elif isinstance(err, (FileNotFoundError, FileExistsError)):
                        logger.error('%s: %s', err.filename,
                                     os.strerror(err.errno))
                        self.retval = 1
                    elif isinstance(err, OperationError):
                        for line in str(err).splitlines():
                            logger.error('%s', line)
                        self.retval = 1
                    elif isinstance(err, (GetoptError, UsageError)):
                        logger.error('%s', err)
                        self.retval = 2
                    elif isinstance(err, (Error, OSError, ValueError)):
                        logger.error('%s', err)
                        self.retval = 1
                    else:
                        raise
        finally:
            if self.hasatty:
                readline.set_completer(oldcompleter)

    def expand(self, words: Sequence['ShellWord']) -> list[str]:
        """Expand :class:`ShellPattern`-s using completions."""
        fnmatchcase = fnmatch.fnmatchcase
        expanded: list[str] = []
        try:
            getfiles = getattr(self, f'complete_{words[0]}')
        except (IndexError, AttributeError):
            getfiles = None
        for i, word in enumerate(words):
            if isinstance(word, ShellPattern) and getfiles:
                matches = sorted(f for f, _ in getfiles(i, '')
                                 if (not f.startswith('.')
                                     and fnmatchcase(f, word)))
                if not matches:
                    raise ValueError(f'{word}: no matches')
                expanded.extend(matches)
            else:
                expanded.append(re.sub(r'\\([*?\[\]])', r'\1', word))
        return expanded

    def getprompt(self) -> str:
        """Get a shell prompt."""
        return '> '

    def execute(self, command: str, *args: str) -> int:
        """Execute `command`.

        Returns:
            `command`'s return value.

        Raises:
            ShellUsageError: Command not found or arguments invalid.
        """
        try:
            method = getattr(self, f'do_{command}')
        except AttributeError as err:
            raise ShellUsageError(f'{command}: no such command') from err
        try:
            inspect.signature(method).bind(*args)
        except TypeError as err:
            raise ShellUsageError(self.getusage(method)) from err
        self.retval = 0 if (retval := method(*args)) is None else retval
        return self.retval

    def executeline(self, line: str) -> int:
        """Execute `line` as a :class:`BaseShell` command.

        Raises:
            ShellUsageError: Command not found or arguments invalid.
        """
        if line.startswith('?'):
            comm = 'help'
            args = self.split(line[1:])
        elif line.startswith('!'):
            comm = 'sh'
            args = self.split(line[1:])
        else:
            try:
                comm, *args = self.expand(self.split(line))
            except ValueError:
                return 0
        return self.execute(comm, *args)

    def runscript(self, script: TextIO) -> int:
        """Run `script`."""
        for line in script:
            self.retval = self.executeline(line.strip())
        return self.retval

    def split(self, line: str) -> list['ShellWord']:
        """Split `line` into words as a POSIX-compliant shell would."""
        buffer: ShellWord = ''
        escape: bool = False
        quotes: list[str] = []
        tokens: list[ShellWord] = []
        for i, char in enumerate(line):
            if escape:
                if char in '*?[]':
                    buffer += '\\'
                buffer += char
                escape = False
            elif quotes:
                if char in '\'"':
                    if char == quotes[-1]:
                        del quotes[-1]
                    else:
                        quotes.append(char)
                    if quotes:
                        buffer += char
                elif char == '\\':
                    escape = True
                else:
                    if char in '*?[]':
                        buffer += '\\'
                    buffer += char
            elif char == '\\':
                escape = True
            elif char in '\'"':
                quotes.append(char)
            elif char.isspace():
                if i > 0 and not line[i - 1].isspace():
                    tokens.append(buffer)
                    buffer = ''
            elif char == '#':
                break
            else:
                if char in '*?[]':
                    buffer = ShellPattern(buffer)
                buffer += char
        if buffer:
            tokens.append(buffer)
        return tokens

    clobber: bool
    """Overwrite files?"""

    commands: tuple[str, ...]
    """Shell commands."""

    logger: logging.Logger = logging.getLogger(__name__)
    """Logger.

    :class:`SieveShell` logs messages of the following priorities:

    * :const:`logging.ERROR`
    * :const:`logging.INFO`
    """

    hasatty: bool = os.isatty(sys.stdin.fileno())
    """Is standard input a terminal?"""

    retval: int = 0
    """Return value of the most recently completed command."""


# pylint: disable=too-many-public-methods
class SieveShell(BaseShell):
    """Shell around a `SieveManager` object."""

    def __init__(self, manager: 'SieveManager', *args,
                 confirm: ShellCmd = ShellCmd.ALL, **kwargs):
        """Initialise a :class:`SieveShell` object.

        Arguments:
            manager: Connection to a ManageSieve server.
            confirm: Shell commands that require confirmation if
                they overwrite or remove files.
        """
        super().__init__(*args, **kwargs)
        self.confirmcmds = confirm
        self.manager = manager

    # Methods
    def getprompt(self, *args, **kwargs):
        prompt = super().getprompt(*args, **kwargs)
        mgr = self.manager
        if mgr and (url := mgr.geturl()) is not None:
            prompt = ('' if mgr.tls else '(insecure) ') + str(url) + prompt
        return prompt

    def execute(self, *args, **kwargs) -> int:
        retval = super().execute(*args, **kwargs)
        if warning := self.manager.warning:
            for line in warning.splitlines():
                self.logger.warning('%s', ctrlescape(line))
        return retval

    def editscripts(self, editor: list[str], *args: str):
        """Edit scripts with the given `editor`."""
        mgr = self.manager

        def retry(err: Exception, script: str) -> bool:
            print(str(err).rstrip('\r\n'), file=sys.stderr)
            return bool(self.confirm(f'Re-edit {script}?',
                        default=ConfirmEnum.YES))

        (opts, scripts) = getopt(list(args), 'a')
        for opt, _ in opts:
            if opt == '-a':
                scripts = [active] if (active := mgr.getactive()) else []
        if scripts:
            mgr.editscripts(editor, list(scripts), catch=retry)
        else:
            self.logger.error('no scripts given')

    # Shell commands
    @staticmethod
    def do_about():
        """about - show information about SieveManager"""
        print(ABOUT.strip())
        print('Home page: https://odkr.codeberg.page/sievemgr')

    def do_activate(self, script: str):
        """activate script - mark script as active"""
        self.manager.setactive(script)

    # pylint: disable=redefined-loop-name
    def do_caps(self):
        """caps - show server capabilities"""
        print('---')
        if (caps := self.manager.capabilities) is not None:
            for key, value in caps.__dict__.items():
                if not value:
                    continue
                if key in ('implementation', 'language',
                           'maxredirects', 'owner', 'version'):
                    if isinstance(value, str):
                        value = yamlescape(value)
                    print(f'{key}: {value}')
                elif key in ('notify', 'sasl', 'sieve'):
                    items = [yamlescape(i) if isinstance(i, str) else str(i)
                             for i in value]
                    # pylint: disable=consider-using-f-string
                    print('{}: [{}]'.format(key, ', '.join(items)))
                elif key in ('starttls', 'unauthenticate'):
                    print(f'{key}: yes')
            for key, value in caps.notunderstood.items():
                if value is not None:
                    if isinstance(value, str):
                        value = yamlescape(value)
                    print(f'{key}: {value}')
        print('...')

    def do_cat(self, *scripts: str):
        """cat [script ...] - concatenate scripts on standard output"""
        for script in scripts:
            sys.stdout.write(self.manager.getscript(script))

    def do_cd(self, localdir: str = HOME):
        """cd [localdir] - change local directory"""
        os.chdir(localdir)
        self.logger.info('changed directory to %s', localdir)

    # pylint: disable=redefined-loop-name
    def do_cert(self):
        """cert - show the server's TLS certificate."""
        indent = ' ' * 4
        mgr = self.manager
        if not isinstance(mgr.sock, ssl.SSLSocket):
            raise ShellUsageError('not a secure connection')
        cert = mgr.sock.getpeercert()
        assert cert
        value: Any
        print('---')
        for key, value in sorted(cert.items()):
            if key in ('OCSP', 'caIssuers', 'crlDistributionPoints'):
                print(f'{key}:')
                for item in sorted(value):
                    if isinstance(item, str):
                        item = yamlescape(item)
                    print(f'{indent}- {item}')
            elif key in ('issuer', 'subject'):
                print(f'{key}:')
                for pairs in sorted(value):
                    print(f'{indent}- ', end='')
                    for i, (k, v) in enumerate(pairs):
                        if isinstance(v, str):
                            v = yamlescape(v)
                        if i == 0:
                            print(f'{k}: {v}')
                        else:
                            print(f'{indent}  {k}: {v}\n')
            elif key == 'subjectAltName':
                print(f'{key}:')
                for k, v in sorted(value):
                    v = yamlescape(v)
                    print(f'{indent}- {k}: {v}')
            elif isinstance(value, str):
                value = yamlescape(value)
                print(f'{key}: {value}')
            elif isinstance(value, int):
                print(f'{key}: {value}')
        print('...')

    def do_check(self, localscript: str):
        """check localscript - check whether localscript is valid"""
        with open(localscript, 'rb', encoding='utf8') as file:
            # checkscript performs a blocking send/sendline,
            # but holding a lock should be okay in an interactive app.
            fcntl.flock(file.fileno(), LOCK_SH | LOCK_NB)
            self.manager.checkscript(file)
        self.logger.info('%s is valid', localscript)

    def do_cmp(self, *args: str) -> int:
        """cmp [-s] script1 [...] scriptN - compare scripts"""
        silent = False
        opts, scripts = getopt(list(args), 's')
        for opt, _ in opts:
            if opt == '-s':
                silent = True
        if len(scripts) < 2:
            message = self.getusage(self.do_cmp)
            assert message
            raise ShellUsageError(message)
        prefix = ', '.join(scripts)
        contents = map(self.manager.getscript, scripts)
        iters = map(str.splitlines, contents)
        for i, lines in enumerate(itertools.zip_longest(*iters), start=1):
            for j, chars in enumerate(itertools.zip_longest(*lines), start=1):
                char1 = chars[0]
                for char2 in chars[1:]:
                    if char1 != char2:
                        if not silent:
                            print(f'{prefix}: line {i}, column {j} differs')
                        return 1
        if not silent:
            print(f'{prefix}: equal')
        return 0

    def do_cp(self, *args: str):
        """cp [-f|-i] source target - re-upload source as target"""
        clobber = self.clobber
        confirm = bool(self.confirmcmds & ShellCmd.CP)
        opts, scripts = getopt(list(args), 'Cfi')
        for opt, _ in opts:
            if opt == '-f':
                clobber = True
                confirm = False
            elif opt == '-i':
                clobber = True
                confirm = True
        try:
            source, target = scripts
        except ValueError as err:
            message = self.getusage(self.do_cp)
            assert message
            raise ShellUsageError(message) from err
        if self.manager.scriptexists(target):
            if not clobber:
                raise FileExistsError(EEXIST, os.strerror(EEXIST), target)
            if confirm and not self.confirm(f'Overwrite {target}?'):
                return
        self.manager.copyscript(source, target)

    def do_deactivate(self):
        """deactivate - deactivate the active script"""
        self.manager.unsetactive()

    def do_diff(self, *args: str) -> int:
        """diff <options> script1 script2 - show how scripts differ"""
        pairs, scripts = getopt(list(args), 'C:U:bcu')
        opts = tuple(filter(bool, itertools.chain(*pairs)))
        if len(scripts) != 2:
            message = self.getusage(self.do_diff)
            assert message
            raise ShellUsageError(message)
        cp = self.manager.editscripts(['diff', *opts], scripts, check=False)
        return cp.returncode

    def do_echo(self, *args: str):
        """echo word [...] - print words to standard output."""
        print(*args)

    def do_ed(self, *args: str):
        """ed [-a] script [...] - edit scripts with a line editor"""
        self.editscripts(EDITOR, *args)

    def do_exit(self) -> bool:
        """exit - exit SieveManager"""
        raise StopIteration()

    def do_get(self, *args: str):
        """get [-a] [-f|-i] [-o file] [script ...] - download script"""
        mgr = self.manager
        opts, sources = getopt(list(args), 'afio:')
        output = ''
        clobber = self.clobber
        confirm = bool(self.confirmcmds & ShellCmd.GET)
        multi = len(sources) > 1
        for opt, arg in opts:
            if opt == '-a':
                sources = [active] if (active := mgr.getactive()) else []
            elif opt == '-f':
                clobber = True
                confirm = False
            elif opt == '-i':
                clobber = True
                confirm = True
            elif opt == '-o':
                if multi:
                    raise ShellUsageError('-o: too many sources')
                output = arg
        answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
        keep = mgr.backups
        for src in sources:
            # Try not to make a backup if the source doesn't exist.
            # Writing to a temporary file would create a worse race condition.
            if keep > 0:
                if not mgr.scriptexists(src):
                    raise FileNotFoundError(ENOENT, os.strerror(ENOENT), src)
            targ = output if output else src
            flags = O_CREAT | O_EXCL | O_WRONLY | O_TRUNC
            if path.exists(targ):
                if not clobber:
                    raise FileExistsError(EEXIST, os.strerror(EEXIST), targ)
                if answer not in (ConfirmEnum.ALL, ConfirmEnum.NONE):
                    answer = self.confirm(f'Overwrite {targ}?', multi=multi)
                if answer:
                    def getfiles() -> Iterator[str]:
                        # pylint: disable=cell-var-from-loop
                        return readdir(path.dirname(targ), path.isfile)
                    backup(targ, keep, getfiles, shutil.copy, os.remove)
                    flags &= ~O_EXCL
                else:
                    continue
            fd = os.open(targ, flags, mode=0o644)
            # getscript performs a blocking read,
            # but holding a lock should be okay in an interactive app.
            fcntl.flock(fd, LOCK_SH | LOCK_NB)
            with os.fdopen(fd, 'w', encoding='utf8') as file:
                file.write(mgr.getscript(src))
            self.logger.info('downloaded %s as %s', src, targ)

    def do_help(self, name: Optional[str] = None):
        """help [command] - list commands/show help for command"""
        if name:
            try:
                print(getattr(self, f'do_{name}').__doc__)
            except AttributeError:
                self.logger.error('%s: unknown command', name)
        else:
            columnise(self.commands)

    def do_ls(self, *args: str):
        """ls [-1al] [script ...] - list scripts"""
        active = False
        long = False
        one = not self.hasatty
        (opts, fnames) = getopt(list(args), '1al')
        for opt, _ in opts:
            if opt == '-1':
                one = True
            elif opt == '-a':
                active = True
            elif opt == '-l':
                long = True
        if active:
            if script := self.manager.getactive():
                print(script)
            else:
                self.logger.warning('no active script')
        else:
            scripts = self.manager.listscripts()
            if fnames:
                existing = {fname for fname, _ in scripts}
                for name in set(fnames) - existing:
                    raise FileNotFoundError(ENOENT, os.strerror(ENOENT), name)
                scripts = [s for s in scripts if s[0] in fnames]
            scripts.sort()
            if long:
                for fname, active in scripts:
                    print('a' if active else '-', fname)
                print('e')
            elif one:
                for script, _ in scripts:
                    print(script)
            else:
                words = [f + ('*' if a else '') for f, a in scripts]
                columnise(words)

    def do_more(self, *args: str):
        """more <options> script [...] - display scripts page-by-page."""
        mgr = self.manager
        pairs, scripts = getopt(list(args), 'aceip:s')
        opts = list(filter(bool, itertools.chain(*pairs)))
        if '-a' in opts:
            active = mgr.getactive()
            if active is None:
                self.logger.warning('no active script')
                return
            scripts = [active]
            opts.remove('-a')
        elif not scripts:
            message = self.getusage(self.do_more)
            assert message
            raise ShellUsageError(message)
        mgr.editscripts(PAGER + opts, list(scripts))

    def do_mv(self, *args: str):
        """mv [-f|-i] source target - rename source to target"""
        clobber = self.clobber
        confirm = bool(self.confirmcmds & ShellCmd.MV)
        mgr = self.manager
        opts, scripts = getopt(list(args), 'Cfi')
        for opt, _ in opts:
            if opt == '-f':
                clobber = True
                confirm = False
            elif opt == '-i':
                clobber = True
                confirm = True
        try:
            source, target = scripts
        except ValueError as err:
            message = self.getusage(self.do_mv)
            assert message
            raise ShellUsageError(message) from err
        if mgr.scriptexists(target):
            if not clobber:
                raise FileExistsError(EEXIST, os.strerror(EEXIST), target)
            if confirm and not self.confirm(f'Overwrite {target}?'):
                return
            mgr.backupscript(target)
            mgr.deletescript(target)
        mgr.renamescript(source, target, emulate=True)

    def do_put(self, *args: str):
        """put [-f|-i] [-a] [-o name] [localscript ...] - upload scripts"""
        active: Optional[str] = None
        clobber: bool = self.clobber
        confirm: bool = bool(self.confirmcmds & ShellCmd.PUT)
        mgr: SieveManager = self.manager
        output: str = ''
        activate: bool = False
        opts, sources = getopt(list(args), 'Cafio:')
        multi = len(sources) > 1
        for opt, arg in opts:
            if opt == '-a':
                if multi:
                    raise ShellUsageError('-a: only one script can be active')
                active = mgr.getactive()
                activate = True
                if active:
                    output = active
            elif opt == '-f':
                clobber = True
                confirm = False
            elif opt == '-i':
                clobber = True
                confirm = True
            elif opt == '-o':
                if multi:
                    raise ShellUsageError('-o: too many sources')
                output = arg
        answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
        for src in sources:
            targ = output if output else src
            if mgr.scriptexists(targ):
                if not clobber:
                    raise FileExistsError(EEXIST, os.strerror(EEXIST), targ)
                if answer not in (ConfirmEnum.ALL, ConfirmEnum.NONE):
                    answer = self.confirm(f'Overwrite {targ}?', multi=multi)
                if not answer:
                    continue
            with open(src, 'rb', encoding='utf8') as file:
                # checkscript performs a blocking send/sendline,
                # but holding a lock should be okay in an interactive app.
                fcntl.flock(file.fileno(), LOCK_SH | LOCK_NB)
                mgr.putscript(file, targ)
            if activate and targ != active:
                mgr.setactive(targ)

    def do_python(self):
        """python - enter Python read-evaluate-print loop"""
        try:
            readline.clear_history()
        except AttributeError:
            pass
        try:
            banner = (f'Python {sys.version}\n'
                       'enter "dir()" to list objects, '
                       '"help()" for help, and '
                       '"exit()" to exit')
            code.interact(local=ObjWrapper(self.manager),
                          banner=banner, exitmsg='')
        except SystemExit:
            pass

    def do_rm(self, *args: str):
        """rm [-f|-i] [script ...] - remove script"""
        confirm = bool(self.confirmcmds & ShellCmd.RM)
        opts, scripts = getopt(list(args), 'fi')
        for opt, _ in opts:
            if opt == '-f':
                confirm = False
            elif opt == '-i':
                confirm = True
        answer = ConfirmEnum.NO if confirm else ConfirmEnum.ALL
        multi = len(scripts) > 1
        for script in scripts:
            if answer is not ConfirmEnum.ALL:
                answer = self.confirm(f'Remove {script}?', multi=multi)
                if answer is ConfirmEnum.NONE:
                    self.logger.info('stopped')
                    break
            if answer:
                self.manager.deletescript(script)

    def do_sh(self, *args: str):
        """sh [command] [argument ...] - run system command or shell"""
        if not args:
            args = (pwd.getpwuid(os.getuid()).pw_shell,)
        subprocess.run(args, check=True)

    def do_su(self, user: str):
        """su user - manage scripts of user."""
        mgr = self.manager
        login = mgr.login
        if mgr.login:
            mgr.unauthenticate()
        # Some ManageSieve servers reject the first
        # "AUTHENTICATE" after an "UNAUTHENTICATE".
        for i in range(2):
            try:
                mgr.authenticate(login, *mgr.auth, owner=user,
                                 sasl=mgr.sasl, **mgr.kwauth)
            except SieveOperationError:
                if i:
                    raise
                continue
            break

    def do_vi(self, *args: str):
        """vi [-a] script [...] - edit scripts with a visual editor"""
        self.editscripts(VISUAL, *args)

    def do_xargs(self, command: str, *args: str):
        """xargs cmd [arg ...] - call cmd with arguments from standard input"""
        try:
            func = getattr(self, f'do_{command}')
        except AttributeError as err:
            raise ShellUsageError(f'{command}: no such command') from err
        lines = []
        try:
            while line := sys.stdin.readline().rstrip('\n'):
                lines.append(line)
        except EOFError:
            pass
        return func(*args, *lines)

    # Globbing and tab-completion
    @staticmethod
    def complete_dirs(_: int, text: str) -> list[tuple[str, bool]]:
        """Complete local directory names."""
        return [(d + '/', False)
                for d in readdir(path.dirname(text), path.isdir)]

    @staticmethod
    def complete_files(_: int, text: str) -> list[tuple[str, bool]]:
        """Complete local filenames."""
        return [(f + '/', False) if path.isdir(f) else (f, True)
                for f in readdir(path.dirname(text))]

    def complete_scripts(self, *_) -> list[tuple[str, bool]]:
        """Complete script names."""
        return [(s, True) for s, _ in self.manager.listscripts(cached=True)]

    complete_activate = complete_scripts
    """Completer for activate."""

    complete_cat = complete_scripts
    """Completer for cat."""

    complete_cd = complete_dirs
    """Completer for cd."""

    complete_cmp = complete_scripts
    """Completer for cmp."""

    complete_cp = complete_scripts
    """Completer for cp."""

    complete_check = complete_files
    """Completer for check."""

    complete_diff = complete_scripts
    """Completer for diff."""

    complete_ed = complete_scripts
    """Completer for ed."""

    complete_get = complete_scripts
    """Completer for get."""

    def complete_help(self, *_) -> list[tuple[str, bool]]:
        """Completer for help."""
        return [(c, True) for c in self.commands]

    complete_ls = complete_scripts
    """Completer for ls."""

    complete_more = complete_scripts
    """Completer for more."""

    complete_mput = complete_files
    """Completer for mput."""

    complete_mv = complete_scripts
    """Completer for mv."""

    complete_put = complete_files
    """Completer for put."""

    complete_rm = complete_scripts
    """Completer for rm."""

    complete_vi = complete_scripts
    """Completer for vi."""

    # Properties
    manager: SieveManager
    """Connection to a ManageSieve server."""


class ObjWrapper(dict):
    """Object wrapper for use with :func:`code.interact`.

    Arguments:
        obj: Object to wrap.
    """

    def __init__(self, obj: Any):
        """Initialise a proxy."""
        super().__init__()
        self.obj = obj

    @staticmethod
    def exit():
        """Exit the Python read-evaluate-print loop."""
        raise SystemExit()

    def keys(self):
        """Same as :meth:`dir`."""
        return dir(self.obj) + list(globals().keys())

    def __getitem__(self, key):
        """Get an attribute from :attr:`obj` or func:`globals`."""
        try:
            return getattr(self, key)
        except AttributeError:
            pass
        try:
            return getattr(self.obj, key)
        except AttributeError:
            pass
        return globals()[key]

    obj: object
    """Wrapped object."""


#
# Configuration
#

BaseConfigT = TypeVar('BaseConfigT', bound='BaseConfig')
"""Type variable for :class:`BaseConfig`."""


class BaseConfig(UserDict):
    """Base class for configurations."""

    def __or__(self: BaseConfigT, other) -> BaseConfigT:
        obj = self.__class__()
        obj.__ior__(self)
        obj.__ior__(other)
        return obj

    def __ior__(self: BaseConfigT, other) -> BaseConfigT:
        super().__ior__(other)
        try:
            for key, value in other._sections.items():
                UserDict.__ior__(self._sections[key], value)
        except AttributeError:
            pass
        return self

    def loadfile(self: BaseConfigT, fname: str):
        """Read configuration from `fname`.

        Raises:
            ShellConfigError: Syntax error.
        """
        ptr = self
        cwd = os.getcwd()
        with open(fname) as file:
            os.chdir(path.dirname(fname))
            try:
                for i, line in enumerate(file, start=1):
                    if line != '\n' and not line.lstrip().startswith('#'):
                        try:
                            key, value = line.strip().split(maxsplit=1)
                            if key == self._section:
                                if value not in self._sections:
                                    self._sections[value] = self.__class__()
                                ptr = self._sections[value]
                            else:
                                ptr.set(key, value)
                        except (AttributeError, TypeError, ValueError) as err:
                            message = f'{fname}:{i}: {err}'
                            raise ShellConfigError(message) from err
            finally:
                os.chdir(cwd)

    def set(self: BaseConfigT, name: str, value):
        """Set the configuration variable `name` to `value`.

        raises:
            AttributeError: Bad variable.
        """
        if name.startswith('_'):
            raise AttributeError(f'{name}: private variable')
        try:
            attr = getattr(self, name)
        except AttributeError as err:
            raise AttributeError(f'{name}: no such variable') from err
        if callable(attr):
            raise AttributeError(f'{name}: not a variable')
        try:
            setattr(self, name, value)
        except AttributeError as err:
            raise AttributeError(f'{name}: read-only variable') from err

    @property
    def sections(self: BaseConfigT) -> dict[str, BaseConfigT]:
        """Sections in the loaded configuration files."""
        return self._sections

    _sections: dict[str, Any] = {}
    """Sections in the loaded configuration files."""

    _section: ClassVar[str] = 'section'
    """Name of the statement that starts a section."""


class BaseVar(ABC):
    """Base class for :class:`BaseConfig` attributes.

    For example:

    >>> @dataclasses.dataclass
    >>> class FooConfig(BaseConfig):
    >>>     foo = BoolVar(default=False)
    >>>     bar = NumVar(cls=int, default=0)
    >>>
    >>> foo = FooConfig(bar=1)
    >>> foo.foo
    False
    >>> foo.bar
    1
    >>> foo.foo = 'yes'
    >>> foo.foo
    True
    >>> foo.bar = '2'
    >>> foo.bar
    2
    """

    def __init__(self, default: Any = None):
        """Initialise a configuration variable."""
        self.default = default

    def __get__(self, obj: BaseConfig, _: type) -> Any:
        try:
            return obj[self.name]
        except KeyError:
            return self.default

    def __set__(self, obj: BaseConfig, value: Any):
        if value is None:
            try:
                del obj[self.name]
            except KeyError:
                pass
        else:
            obj[self.name] = value

    def __set_name__(self, _: object, name: str):
        self.name = name

    name: str
    """Variable name."""

    default: Any
    """Default value."""


class ExpandingMixin():
    """Mixin for variables that do word expansion."""

    def expand(self, obj: BaseConfig, value: str) -> str:
        """Expand '~' and configuration variables."""
        assert isinstance(self, BaseVar)
        template = string.Template(value)
        # template.get_identifiers is only available in Python >= v3.11.
        varnames = set()
        # pylint: disable=consider-using-f-string
        for pattern in (r'\$(%s)' % template.idpattern,
                        r'\$\{(%s)\}' % template.idpattern):
            for match in re.finditer(pattern, value, flags=re.IGNORECASE):
                varnames.add(match.group(1))
        name = self.name
        variables = {}
        for n in varnames:
            try:
                value = getattr(obj, n)
            except AttributeError as err:
                raise ValueError(f'{name}: ${n}: no such variable') from err
            if value is None:
                raise ValueError(f'{name}: ${n}: not set')
            if not isinstance(value, (int, str)):
                raise ValueError(f'{name}: ${n}: not a scalar')
            variables[n] = value
        return path.expanduser(template.substitute(variables))


class ListVarMixin():
    """Mixin for lists."""

    splititems: Callable = re.compile(r'\s*,\s*').split
    """Split a comma-separated list into items."""


class BoolVar(BaseVar):
    """Convert "yes" and "no" to :class:`bool`."""

    def __set__(self, obj: BaseConfig, value: Union[bool, str]):
        if value in (True, 'yes'):
            super().__set__(obj, True)
        elif value in (False, 'no'):
            super().__set__(obj, False)
        else:
            raise ValueError(f'{value}: not a boolean')


class CmdVar(BaseVar, ExpandingMixin):
    """Split up value into a list using :func:`shlex.split`."""

    def __set__(self, obj: BaseConfig, value: str):
        super().__set__(obj, shlex.split(value, posix=True))

    def __get__(self, obj: BaseConfig, objtype: type) -> Optional[list[str]]:
        return (None if (value := super().__get__(obj, objtype)) is None else
                [self.expand(obj, word) for word in value])


class EnumVar(BaseVar):
    """Convert comma-separated values to an :class:`enum.Enum`."""

    def __init__(self, *args, cls: type[enum.Enum], **kwargs):
        """Initialise the variable.

        Arguments:
            name: Variable name.
            cls: Enumeration type.
            default: Default value.
        """
        assert issubclass(cls, enum.Enum)
        super().__init__(*args, **kwargs)
        self.cls = cls

    def __set__(self, obj: BaseConfig, value: Union[enum.Enum, str]):
        if isinstance(value, enum.Enum):
            super().__set__(obj, value)
        elif isinstance(value, str):
            for member in self.cls:
                if member.name.casefold() == value.casefold():
                    super().__set__(obj, member)
                    break
            else:
                raise ValueError(f'{value}: no such item')
        else:
            raise TypeError(f'{type(value)}: not an enumeration')


class FileVar(BaseVar, ExpandingMixin):
    """Expand words and make filenames absolute."""

    def __get__(self, obj: BaseConfig, objtype: type) -> Optional[str]:
        value = super().__get__(obj, objtype)
        if value is None:
            return None
        fname = self.expand(obj, value)
        return fname if path.isabs(fname) else path.join(self.basedir, fname)

    def __set__(self, obj: BaseConfig, value: Union[None, str]):
        if not (isinstance(value, str) or value is None):
            raise TypeError('{value}: not a str')
        super().__set__(obj, value)
        self.basedir = os.getcwd()

    basedir: str
    """Directory that the filename should be relative to."""


class FlagVar(BaseVar, ListVarMixin):
    """Convert comma-separated values to an :class:`int`."""

    def __init__(self, *args, cls: type[enum.IntEnum], **kwargs):
        assert issubclass(cls, enum.IntEnum)
        super().__init__(*args, **kwargs)
        self.cls = cls

    def __set__(self, obj: BaseConfig, value: Union[str, int, enum.IntEnum]):
        if isinstance(value, (int, enum.IntFlag)):
            super().__set__(obj, value)
        elif isinstance(value, str):
            flag = 0
            for name in self.splititems(value):
                for member in self.cls:
                    # pylint: disable=bad-indentation
                    if (member.name is not None and
                        name.casefold() == member.name.casefold()):
                            flag |= member.value
                            break
                else:
                    raise ValueError(f'{name}: no such item')
            super().__set__(obj, flag)
        else:
            raise TypeError(f'{value}: neither an int nor a str')

    cls: type[enum.IntEnum]
    """Enumeration type"""


class HostVar(BaseVar):
    """Check whether value is a valid hostname."""

    def __set__(self, obj: BaseConfig, value: str):
        if isinstance(value, str):
            if (match := self._matchhost(value)) is not None:
                for host in match.groupdict().values():
                    if host is not None:
                        super().__set__(obj, host)
            else:
                raise ValueError(f'{value}: neither hostname nor address')
        elif value is None:
            super().__set__(obj, value)
        else:
            raise TypeError(f'{value}: not a string')

    _matchhost: Callable[..., Optional[re.Match]] = re.compile('|'.join((
        r'(?P<ipv4>([0-2]?[0-9]{1,2}\.){3}[0-2]?[0-9]{1,2})',
        r'\[(?P<ipv6>([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4})\]',
        r'(?P<hostname>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9]\.)?)+)',
    )), flags=re.IGNORECASE).fullmatch
    """Matches hosts."""


class NumVar(BaseVar):
    """Convert value to a number of type :attr:`cls`."""

    def __init__(self, *args, cls: type = int,
                 minval: Optional[Union[float, int]] = None,
                 maxval: Optional[Union[float, int]] = None,
                 **kwargs):
        """Initialise the variable.

        Arguments:
            name: Variable name.
            cls: Number type.
            minval: Smallest permissible value.
            maxval: Greatest permissible value.
            default: Default value.
        """
        super().__init__(*args, **kwargs)
        self.cls = cls
        self.minval = minval
        self.maxval = maxval

    def __set__(self, obj: BaseConfig, value: Union[int, float, str]):
        try:
            num = self.cls(value)
        except ValueError as err:
            raise ValueError(f'{value}: not a number') from err
        if self.minval is not None and num < self.minval:
            raise ValueError(f'{value} < {self.minval}')
        if self.maxval is not None and num > self.maxval:
            raise ValueError(f'{value} > {self.maxval}')
        super().__set__(obj, num)

    cls: type
    """Number type."""

    minval: Optional[Union[float, int]]
    """Minimum value."""

    maxval: Optional[Union[float, int]]
    """Maximum value."""


class SASLMechVar(BaseVar, ListVarMixin):
    """Convert SASL mechanism names to :class:`BaseAuth` subclasses."""

    def __set__(self, obj: BaseConfig,
                value: Union[Iterable[type[BaseAuth]], str]):
        if isinstance(value, str):
            classes = BaseAuth.getmechs(obsolete=True)
            mechs = []
            for name in self.splititems(value.casefold()):
                matches = []
                for cls in classes:
                    if fnmatch.fnmatchcase(cls.name.casefold(), name):
                        if cls in mechs:
                            raise ValueError(f'{cls.name}: duplicate')
                        matches.append(cls)
                if not matches:
                    raise ValueError(f'{name}: no matches')
                mechs.extend(matches)
        elif isinstance(value, Sequence):
            mechs = value     # type: ignore[assignment]
        else:
            raise TypeError(f'{value}: not an SASL mechanism')
        if not BaseAuth.getauthtypes(mechs):
            raise ValueError('{value}: cannot be used together')
        super().__set__(obj, mechs)


class UniqueVar(BaseVar):
    """Variable the value of which must be unique."""

    def __set__(self, obj: BaseConfig, value: str):
        values = self.__class__.values
        if value in values:
            raise ValueError(f'{value}: already in use')
        super().__set__(obj, value)
        values.add(value)

    values: ClassVar[set] = set()


SieveConfigT = TypeVar('SieveConfigT', bound='SieveConfig')


class SieveConfig(BaseConfig):
    """Configuration for the SieveManager command-line client."""

    @classmethod
    def fromfiles(cls: type[SieveConfigT], *fnames: str) -> SieveConfigT:
        """Create a new configuration from `fnames`.

        Arguments:
            fnames: Filenames (default: :var:`CONFIGFILES`)

        Raises:
            FileNotFoundError: A given file could not be found.
        """
        obj = cls()
        for fname in (fnames if fnames else CONFIGFILES):
            try:
                obj.loadfile(fname)
            except FileNotFoundError:
                if fnames:
                    raise
        return obj

    def __init__(self, *args, **kwargs):
        """Create a new configuration.

        Arguments:
            args: Positional arguments used to initialise the back-end.
            kwargs: Keyword arguments used as initial configuration values.
        """
        super().__init__(*args)
        for key, value in kwargs.items():
            setattr(self, key, value)

    def getmanager(self, **variables) -> SieveManager:
        """Open a :class:`SieveManager` connection with this configuration.

        Arguments:
            variables: Configuration variables.

        Raises:
            NotImplementedError: Unsupported SASL mechanism.
            ShellConfigError: SASL mechanisms cannot be combined.
            netrc.NetrcParseError: :file:`.netrc` could not be parsed.
        """
        conf = self | self.__class__(**variables)

        # Private SieveManager class
        class _ConfiguredSieveManager(SieveManager):
            pass

        # Logging level
        _ConfiguredSieveManager.logger.setLevel(conf.verbosity)

        # TLS
        sslcontext = _ConfiguredSieveManager.sslcontext
        if (cadir := conf.cadir) or (cafile := conf.cafile):
            sslcontext.load_verify_locations(cafile, cadir)
        if cert := conf.cert:
            def getpassphrase():
                if getpassphrase := conf.getpassphrase:
                    return readoutput(*getpassphrase)
                return askpass('Certificate passphrase: ')
            sslcontext.load_cert_chain(cert, conf.key, getpassphrase)
        if conf.x509strict:
            sslcontext.verify_flags |= ssl.VERIFY_X509_STRICT

        # Connect and authenticate
        logauth = conf.verbosity <= LogLevel.AUTH

        try:
            authtype = BaseAuth.getauthtypes(conf.saslmechs)[-1]
        except IndexError as err:
            raise ShellConfigError('incompatble SASL mechanisms') from err

        mgr = _ConfiguredSieveManager(
                conf.host, '', port=conf.port, timeout=conf.timeout,
                tls=conf.tls, ocsp=conf.ocsp, backups=conf.backups
        )

        if issubclass(authtype, BasePassAuth):
            password = (self.password if self.password else
                        readoutput(*self.getpassword) if self.getpassword else
                        askpass(f"{self.login}@{self.host}'s password: "))
            mgr.authenticate(conf.login, password=password, owner=conf.owner,
                             logauth=logauth, prepare=conf.saslprep,
                             sasl=conf.saslmechs)
        elif issubclass(authtype, ExternalAuth):
            mgr.authenticate(conf.login, owner=conf.owner,
                             logauth=logauth, prepare=conf.saslprep,
                             sasl=conf.saslmechs)
        else:
            raise NotImplementedError('unsupported authentication type')

        return mgr

    def loadaccount(self, host: str = 'localhost',
                    login: Optional[str] = None):
        """Load the section for `login` on `host`."""
        self |= self.__class__(host=host, login=login)
        hosts = readnetrc(self.netrc)

        # Host
        for name, section in self.sections.items():
            if section.alias == self.host:
                try:
                    self.host = section['host']
                except KeyError:
                    self.host = name.rsplit('@', maxsplit=1)[-1]
                break
        try:
            self |= self.sections[self.host]
        except KeyError:
            pass

        # Login
        if not self.login:
            try:
                self.login = hosts[self.host][0]
            except KeyError:
                self.login = getpass.getuser()
        try:
            self |= self.sections[f'{self.login}@{self.host}']
        except KeyError:
            pass

        # Password
        if not self.password:
            try:
                self.password = hosts[self.host][2]
            except KeyError:
                pass

    alias: UniqueVar = UniqueVar()
    """Alias for a host."""

    backups = NumVar(cls=int, default=0, minval=0)
    """Backup policy."""

    cadir = FileVar()
    """Custom CA directory."""

    cafile = FileVar()
    """Custom CA file."""

    clobber = BoolVar(default=True)
    """Overwrite files?"""

    confirm = FlagVar(default=ShellCmd.ALL, cls=ShellCmd)
    """Which shell commands have to be confirmed?"""

    cert = FileVar()
    """Client TLS certificate."""

    getpassphrase = CmdVar()
    """Command that prints the passphrase for the TLS key."""

    getpassword = CmdVar()
    """Command that prints a password."""

    host = HostVar(default='localhost')
    """Host to connect to by default."""

    key = FileVar()
    """Client TLS key."""

    login = BaseVar()
    """User to login as (authentication ID)."""

    netrc: Optional[str] = os.getenv('NETRC')
    """Filename of the .netrc file."""

    ocsp = BoolVar(default=True)
    """Check whether server certificate was revoked?"""

    owner = BaseVar(default='')
    """User whose scripts to manage (authorisation ID)."""

    password = BaseVar()
    """Password to login with."""

    port = NumVar(default=4190, minval=0, maxval=65535)
    """Port to connect to by default."""

    saslmechs = SASLMechVar(default=BasePassAuth.getmechs())
    """How to authenticate."""

    saslprep = FlagVar(default=SASLPrep.ALL, cls=SASLPrep)
    """Which credentials to prepare."""

    timeout = NumVar(default=socket.getdefaulttimeout(), cls=float, minval=0)
    """Network timeout."""

    tls = BoolVar(default=True)
    """Use TLS?"""

    verbosity = EnumVar(default=LogLevel.INFO, cls=LogLevel)
    """Logging level."""

    x509strict = BoolVar(default=True)
    """Be strict when verifying TLS certificates?"""

    _section = 'account'


#
# Terminal I/O
#

class TermIO(io.TextIOWrapper):
    """I/O for the controlling terminal."""

    def __init__(self, *args, **kwargs):
        """Open the controlling terminal."""
        super().__init__(io.FileIO('/dev/tty', 'r+'), *args, **kwargs)


#
# Logging
#

class LogIOWrapper():
    """Logger for file-like objects."""

    @classmethod
    def wrapfile(cls, file: Union[BinaryIO, io.BufferedRWPair],
                 encoding: str = 'utf8', level: int = logging.DEBUG,
                 logger: logging.Logger = logging.getLogger(__name__),
                 formats: tuple[str, str] = ('S: %s', 'C: %s')):
        """Wrap a file in a :class:`LogIOWrapper` if logging is enabled.

        Takes the same arguments as :meth:`__init__`.

        Returns:
            The file or a :class:`LogIOWrapper` that wraps the file.
        """
        if logger.isEnabledFor(level):
            return cls(file, encoding, level, logger, formats)
        return file

    def __init__(self, file: Union[BinaryIO, io.BufferedRWPair],
                 encoding: str = 'utf8', level: int = logging.DEBUG,
                 logger: logging.Logger = logging.getLogger(__name__),
                 formats: tuple[str, str] = ('S: %s', 'C: %s')):
        """Log I/O to `file`.

        Arguments:
            file: File-like object opened in binary mode.
            encoding: `file`'s encoding.
            level: Logging priority.
            logger: Logger.
            formats: Message formats; '%s' is replaced with I/O.
        """
        splitlines = re.compile(rb'\r?\n').split
        buffers = (bytearray(), bytearray())

        def extv(buf: bytearray, vec: Iterable[bytes]):
            for elem in vec:
                buf.extend(elem)

        def getdecorator(
                buf: bytearray, fmt: str, arg=None,
                ext: Callable[[bytearray, Iterable], None] = bytearray.extend
                ) -> Callable[[Callable[..., T]], Callable[..., T]]:
            def decorator(op: Callable[..., T]) -> Callable[..., T]:
                def wrapper(*args, **kwargs) -> T:
                    retval = op(*args, **kwargs)
                    if not self.quiet:
                        ext(buf, retval if arg is None else args[arg])
                        ptr: Union[bytes, bytearray] = buf
                        while True:
                            try:
                                line, ptr = splitlines(ptr, maxsplit=1)
                            except ValueError:
                                break
                            self.log(line, fmt)
                        buf[:] = ptr
                    return retval
                return wrapper
            return decorator

        logread = getdecorator(buffers[0], formats[0])
        logreadinto = getdecorator(buffers[0], formats[0], arg=0)
        logreadv = getdecorator(buffers[0], formats[0], ext=extv)
        logwrite = getdecorator(buffers[1], formats[1], arg=0)
        logwritev = getdecorator(buffers[1], formats[1], arg=0, ext=extv)

        self.read = logread(file.read)
        self.readline = logread(file.readline)
        self.readlines = logreadv(file.readlines)
        self.write = logwrite(file.write)
        self.writelines = logwritev(file.writelines)

        if isinstance(file, io.RawIOBase):
            self.readall = logread(file.readall)
            self.readinto = logreadinto(file.readinto)

        if isinstance(file, io.BufferedIOBase):
            self.read1 = logread(file.read1)
            self.readinto1 = logread(file.readinto1)
            self.readinto = logreadinto(file.readinto)

        self.buffers = buffers
        self.encoding = encoding
        self.formats = formats
        self.file = file
        self.level = level
        self.logger = logger

    def __del__(self):
        file = self.file
        if not file.closed:
            file.flush()
            file.close()
        if not self.quiet:
            for buf, fmt in zip(self.buffers, self.formats):
                if buf:
                    self.log(buf, fmt)

    def __getattr__(self, name):
        return getattr(self.file, name)

    def __iter__(self):
        return self

    def __next__(self):
        if line := self.readline():
            return line
        raise StopIteration()

    def log(self, line: bytes, fmt: str):
        """Log `line` with `fmt`."""
        decoded = ctrlescape(line.rstrip(b'\r\n').decode(self.encoding))
        self.logger.log(self.level, fmt, decoded)

    buffers: tuple[bytearray, bytearray]
    """Logging buffers."""

    encoding: str
    """:attr:`file`'s encoding."""

    file: Union[BinaryIO, io.BufferedRWPair]
    """Underlying file-like object."""

    formats: tuple[str, str]
    """Logging formats."""

    level: int
    """Logging level."""

    logger: logging.Logger
    """Logger."""

    quiet: bool = False
    """Log I/O?"""

    read: Callable[..., bytes]
    """Read from :attr:`file`."""

    readinto: Callable[..., int]
    """Read from :attr:`file` into a buffer."""

    readline: Callable[..., bytes]
    """Read a line from :attr:`file`."""

    readlines: Callable[..., list[bytes]]
    """Read all lines from :attr:`file`."""

    write: Callable[[bytes], Optional[int]]
    """Write to :attr:`file`."""

    writelines: Callable[[Iterable[bytes]], Optional[int]]
    """Write lines to :attr:`file`."""


#
# Errors
#

# Error types
class Error(Exception):
    """Base class for errors."""


class ProtocolError(Exception):
    """Base class for protocol errors.

    .. danger::
        Continuing after a :exc:`ProtocolError` may result in data loss.
    """


class SecurityError(Exception):
    """Base class for security errors.

    .. danger::
        Continuing after a :exc:`SecurityError` is unsafe.
    """


class CapabilityError(Error):
    """Base class for capability errors."""


class ConfigError(Error):
    """Base class for configuration errors."""


class DataError(Error):
    """Base class for data errors."""


class OperationError(Error):
    """Base class for operation errors."""


class UsageError(Error):
    """Base class for usage errors."""


# Client errors
class ClientError(Error):
    """Base class for client errors."""


class ClientConnectionError(ClientError, ConnectionError):
    """Connection error."""


class ClientOperationError(ClientError, OperationError):
    """Client-side operation error."""


# DNS errors
class DNSError(Error):
    """Base class for DNS errors."""


class DNSOperationError(DNSError, OperationError):
    """DNS operation error."""


# HTTP errors
class HTTPError(Error):
    """Base class for HTTP errors."""


class HTTPOperationError(HTTPError, OperationError):
    """HTTP operation error."""


class HTTPUsageError(HTTPError, ProtocolError):
    """HTTP usage error."""


# OCSP errors
class OCSPError(Error):
    """Base class for OCSP_ errors."""


class OCSPDataError(OCSPError, DataError):
    """OCSP_ data error."""


class OCSPOperationError(OCSPError, OperationError):
    """OCSP_ operation error."""


# SASL errors
class SASLError(Error):
    """Base class for SASL errors."""


class SASLCapabilityError(Error):
    """SASL capability error."""


class SASLProtocolError(SASLError, ProtocolError):
    """Server violated the SASL protocol."""


class SASLSecurityError(SASLError, SecurityError):
    """SASL security error."""


# Shell errors
class ShellError(Error):
    """Base class for shell errors."""


class ShellConfigError(ClientError, ConfigError):
    """Shell configuration error."""


class ShellUsageError(ShellError, UsageError):
    """Shell usage error."""


# ManageSieve errors
class SieveError(Error):
    """Base class for ManageSieve errors."""


class SieveCapabilityError(SieveError, CapabilityError):
    """Capability not supported by the server."""


class SieveConnectionError(Response, SieveError, ConnectionError):
    """Server said "BYE"."""

    # pylint: disable=redefined-outer-name
    def __init__(self, response: str = 'bye',
                 code: tuple[ACAPWord, ...] = (),
                 message: Optional[str] = None):
        super().__init__(response=response, code=code, message=message)


class SieveOperationError(Response, SieveError):
    """Server said "NO"."""

    # pylint: disable=redefined-outer-name
    def __init__(self, response: str = 'no',
                 code: tuple[ACAPWord, ...] = (),
                 message: Optional[str] = None):
        super().__init__(response=response, code=code, message=message)


class SieveProtocolError(SieveError, ProtocolError):
    """Server violated the ManageSieve protocol error."""


# TLS errors
class TLSError(Error):
    """Base class for TLS errors."""


class TLSCapabilityError(TLSError, CapabilityError):
    """TLS capability error."""


class TLSSecurityError(TLSError, OperationError):
    """TLS security error."""


#
# Exceptions
#

@dataclass(frozen=True)
class SignalCaught(Exception):
    """Signal was caught."""

    @classmethod
    def catch(cls, signo: int, frame: Optional[types.FrameType]):
        """Raise a :exc:`SignalCaught`."""
        raise cls(signo, frame)

    def __str__(self):
        desc = signal.strsignal(self.signo)
        return desc.split(':')[0] if desc else f'caught signal {self.signo}'

    signo: int
    """Signal number."""

    frame: Optional[types.FrameType] = None
    """Stack frame."""


#
# Helpers
#

def askpass(prompt: str) -> str:
    """Prompt for a password on the controlling terminal."""
    with TermIO() as tty:
        return getpass.getpass(prompt, stream=tty)


def backup(file: str, keep: int, getfiles: Callable[[], Iterable[str]],
           copy: Callable[[str, str], Any], remove: Callable[[str], Any]):
    """Make an Emacs-style backup of `file`.

    `keep` = 1
        :file:`file` is backed up as :file:`file~`.

    `keep` > 1
        :file:`file` is backed up as :file:`file.~{n}~`, where
        `n` starts with 1 and incremetns with each backup.

    Arguments:
        file: File to back up.
        keep: How many copies to keep.
        copy: Function that copies the file.
        getfiles: Function that returns a list of files.
        remove: Function that removes a file.

    Raises:
        ValueError: `keep` is < 0.
    """
    if keep < 0:
        raise ValueError('keep: must be >= 0')
    if keep == 0:
        return
    if keep == 1:
        copy(file, file + '~')
    else:
        backupexpr = re.escape(file) + r'\.~(\d+)~'
        matchbackup = re.compile(backupexpr).fullmatch
        backups = sorted((int(match.group(1)), file) for file in getfiles()
                         if (match := matchbackup(file)))
        for _, bak in backups[:-(keep - 1)]:
            remove(bak)
        counter = backups[-1][0] + 1 if backups else 1
        copy(file, f'{file}.~{counter}~')


def certrevoked(cert: 'x509', logger: logging.Logger =
                logging.getLogger(__name__)) -> bool:
    """Check if `cert` has been revoked.

    Raises:
        OCSPDataError: `cert` contains no authority information.
        OCSPOperationError: no authoritative response.

    .. seealso::
        :RFC:`5019`
            Lightweight OCSP Profile
        :RFC:`6960`
            Online Certificate Status Protocol (OCSP)
    """
    try:
        issuers, responders = getcertauthinfo(cert)
    except ExtensionNotFound as err:
        raise OCSPDataError('no authority information') from err
    for caurl in issuers:
        try:
            der = httpget(caurl)
        except (urllib.error.URLError, HTTPError) as err:
            logger.error(err)
            continue
        ca = x509.load_der_x509_certificate(der)
        builder = ocsp.OCSPRequestBuilder()
        # SHA1 is mandated by RFC 5019.
        req = builder.add_certificate(cert, ca, SHA1()).build()  # nosec B303
        # pylint: disable=redefined-outer-name
        path = b64encode(req.public_bytes(Encoding.DER)).decode('ascii')
        for responder in responders:
            statusurl = urllib.parse.urljoin(responder, path)
            try:
                res = ocsp.load_der_ocsp_response(httpget(statusurl))
            except HTTPError as err:
                logger.error(err)
                continue
            if res.response_status == OCSPResponseStatus.SUCCESSFUL:
                now = datetime.datetime.now()
                if res.this_update <= now < res.next_update:
                    if res.certificate_status == OCSPCertStatus.REVOKED:
                        return True
                    if res.certificate_status == OCSPCertStatus.GOOD:
                        return False
    raise OCSPOperationError('no authoritative OCSP response')


def ctrlescape(chars: str) -> str:
    """Escape control characters."""
    categories = map(unicodedata.category, chars)
    escaped = [fr'\u{ord(char):04x}' if cat.startswith('C') else char
               for char, cat in zip(chars, categories)]
    return ''.join(escaped)


def columnise(words: Sequence[str], file: TextIO = sys.stdout):
    """Print `words` in columns."""
    if (nwords := len(words)) > 0:
        colwidth = max(map(len, words)) + 1
        maxncols = shutil.get_terminal_size().columns // colwidth
        nrows = math.ceil(nwords / maxncols)
        table: tuple[list[str], ...] = tuple([] for _ in range(nrows))
        for i, word in enumerate(words):
            table[i % nrows].append(word)
        for row in table:
            print(''.join([col.ljust(colwidth) for col in row]), file=file)


def httpget(url: str) -> bytes:
    """Download a file from `url` using HTTP.

    Raises:
        HTTPUsageError: `url` is not an HTTP URL.
        HTTPOperationError: "GET" failed.
    """
    while True:
        if not url.startswith('http://'):
            raise HTTPUsageError(f'{url}: not an HTTP URL')
        with urllib.request.urlopen(url) as res:  # nosec B310
            if res.status == 200:
                return res.read()
            if res.status in (301, 302, 303, 307, 308):
                if url := res.getheader('Location'):
                    continue
        raise HTTPOperationError(f'GET {url}: {res.reason}')
    # NOTREACHED


def getcertauthinfo(cert: 'x509') -> tuple[list[str], list[str]]:
    """Get information about the authority that issued `cert`.

    Returns:
        CA issuer URLs and OCSP responder base URLs.
    """
    exts = cert.extensions.get_extension_for_class(AuthorityInformationAccess)
    issuers = []
    responders = []
    for field in exts.value:
        oid = field.access_method
        if oid == AuthorityInformationAccessOID.CA_ISSUERS:
            issuers.append(field.access_location.value)
        elif oid == AuthorityInformationAccessOID.OCSP:
            responders.append(field.access_location.value)
    return issuers, responders


def getfilesize(file: IO) -> int:
    """Get the size of file-like object relative to the current position."""
    try:
        pos = file.tell()
    except io.UnsupportedOperation:
        pos = 0
    try:
        size = os.fstat(file.fileno()).st_size - pos
    except io.UnsupportedOperation:
        size = file.seek(0, SEEK_END) - pos
        file.seek(pos, SEEK_SET)
    return size


def isipaddr(addr: str) -> bool:
    """Check whether `addr` is an IP address."""
    try:
        ipaddress.ip_address(addr)
    except ValueError:
        return False
    return True


def randomise(elems: Sequence[T], weights: Iterable[int]) -> list[T]:
    """Randomise the order of `elems`."""
    nelems = len(elems)
    weights = list(weights)
    indices = list(range(len(elems)))
    randomised = []
    for _ in range(nelems):
        i, = random.choices(indices, weights, k=1)  # noqa DUO102 # nosec B311
        randomised.append(elems[i])
        weights[i] = 0
    return randomised


def readdir(dirname: str, predicate: Optional[Callable[[str], bool]] = None) \
        -> Iterator[str]:
    """Get every filename in `dirname` that matches `predicate`."""
    for dirent in os.listdir(dirname if dirname else '.'):
        fname = path.join(dirname, dirent)
        if predicate is None or predicate(fname):
            yield fname


def readnetrc(fname: Optional[str]) -> dict[str, tuple[str, str, str]]:
    """Read a .netrc file.

    Arguments:
        fname: Filename (default: :file:`~/.netrc`)

    Returns:
        Mapping from hostnames to login-account-password 3-tuples.

    Raises:
        FileNotFoundError: `fname` was given but not found.
        netrc.NetrcParseError: Syntax error.
    """
    try:
        if fname:
            return netrc.netrc(fname).hosts
        try:
            return netrc.netrc().hosts
        except FileNotFoundError:
            pass
    except netrc.NetrcParseError as err:
        if sys.version_info < (3, 10):
            logging.error(err)
        else:
            raise
    return {}


def readoutput(*command: str, encoding: str = locale.getpreferredencoding()):
    """Read and decode the output of `command`."""
    logging.debug('exec: %s', ' '.join(command))
    cp = subprocess.run(command, capture_output=True, check=True)
    return cp.stdout.rstrip().decode(encoding)


# pylint: disable=redundant-returns-doc
def resolvesrv(host: str) -> Iterator[SRV]:
    """Resolve a DNS SRV record.

    Arguments:
        host: Hostname (e.g., :samp:`_sieve._tcp.imap.foo.example`)

    Returns:
        An iterator over `SRV` records sorted by their priority
        and randomised according to their weight.

    .. note::
        Requires dnspython_.

    .. _dnspython: https://www.dnspython.org/
    """
    answer = dns.resolver.resolve(host, 'SRV')
    hosts: dict[str, list] = defaultdict(list)
    byprio: dict[int, list[SRV]] = defaultdict(list)
    for rec in answer.response.additional:
        name = rec.name.to_text()
        addrs = [i.address for i in rec.items if i.rdtype == dns.rdatatype.A]
        hosts[name].extend(addrs)
    for rec in answer:
        name = rec.target.to_text()
        if addrs := hosts.get(name):    # type: ignore[assignment]
            srvs = [SRV(rec.priority, rec.weight, a, rec.port) for a in addrs]
            byprio[rec.priority].extend(srvs)
        else:
            srv = SRV(rec.priority, rec.weight, name.rstrip('.'), rec.port)
            byprio[rec.priority].append(srv)
    for prio in sorted(byprio.keys()):
        srvs = byprio[prio]
        weights = [s.weight for s in srvs]
        if sum(weights) > 0:
            yield from randomise(srvs, weights)
        else:
            yield from srvs


def yamlescape(data: str):
    """Strip and quote `data` for use as YAML scalar if needed."""
    indicators = ('-', '?', ':', ',', '[', ']', '{', '}', '#', '&',
                  '*', '!', '|', '>', "'", '"', '%', '@', '`')
    data = data.strip()
    if not data:
        return '""'
    if data == 'no':
        return '"no"'
    if data[0] in indicators or ': ' in data or ' #' in data:
        return '"' + data.replace('\\', '\\\\').replace('"', '\\\"') + '"'
    return data


#
# Main
#

# pylint: disable=too-many-branches, too-many-statements
def sievemgr() -> int:
    """sievemgr - manage remote Sieve scripts

    Usage:  sievemgr [server] [command] [argument ...]
            sievemgr -e expression [...] [server]
            sievemgr -s file [server]

    Options:
        -C             Do not overwrite existing files.
        -c file        Read configuration from file.
        -d             Enable debugging mode.
        -e expression  Execute expression on the server.
        -f             Overwrite and remove files without confirmation.
        -i             Confirm removing or overwriting files.
        -o var=value   Set configuration var to value.
        -q             Be quieter.
        -s file        Execute expressions read from file.
        -v             Be more verbose.

        -e, -o, -q, and -v can be given multiple times.
        See sievemgr(1) for the complete list.

    Home page: https://odkr.codeberg.page/sievemgr
    """
    progname = path.basename(sys.argv[0])
    logging.basicConfig(format=f'{progname}: %(message)s')

    for signo in (SIGHUP, SIGINT, SIGTERM):
        signal.signal(signo, SignalCaught.catch)

    # Options
    conf = SieveConfig()
    exprs: list[str] = []
    configs: list[str] = []
    script: Optional[TextIO] = None
    volume: int = 0
    opts, args = getopt(sys.argv[1:], 'CN:Vc:de:fhio:qs:v',
                        ['help', 'version'])

    # pylint: disable=protected-access
    for opt, arg in opts:
        try:
            if opt in ('-h', '--help'):
                assert sievemgr.__doc__
                for line in sievemgr.__doc__.splitlines()[:-1]:
                    print(line.removeprefix(' ' * 4))
                sys.exit()
            elif opt in ('-V', '--version'):
                SieveShell.do_about()
                sys.exit()
            elif opt == '-C':
                conf.clobber = False
            elif opt == '-N':
                conf.netrc = arg
            elif opt == '-c':
                configs.append(arg)
            elif opt == '-d':
                # pylint: disable=global-statement
                global DEBUG
                DEBUG = True
                conf.verbosity = LogLevel.DEBUG
            elif opt == '-e':
                exprs.append(arg)
            elif opt == '-f':
                conf.confirm = ShellCmd.NONE
            elif opt == '-i':
                conf.confirm = ShellCmd.ALL
            elif opt == '-o':
                value: Union[bool, str]
                try:
                    name, value = arg.split('=', maxsplit=1)
                except ValueError:
                    if arg.startswith('no'):
                        name, value = arg[2:], False
                    else:
                        name, value = arg, True
                if not value:
                    raise ValueError(f'{name}: empty')
                try:
                    conf.set(name, value)
                except (AttributeError, TypeError, ValueError) as err:
                    raise err.__class__(f'{name}: {err}') from err
            elif opt == '-q':
                volume -= 1
            elif opt == '-s':
                # pylint: disable=consider-using-with
                script = open(arg, 'r')
            elif opt == '-v':
                volume += 1
        except (AttributeError, TypeError, ValueError) as err:
            raise ShellUsageError(f'{opt}: {err}') from err

    # Arguments
    url = URL.fromstr(args.pop(0)) if args else None
    command = args.pop(0) if args else ''

    if script:
        if command:
            raise ShellUsageError('-s cannot be used with a command')
        if exprs:
            raise ShellUsageError('-e and -s cannot be combined')

    # Configuration
    conf = SieveConfig().fromfiles() | conf
    conf.loadaccount(host=url.hostname if url else conf.host,
                     login=url.username if url else None)
    if url:
        conf.owner = url.owner

    # Logging
    conf.verbosity = conf.verbosity.fromdelta(volume)
    logger = logging.getLogger()
    logger.setLevel(conf.verbosity)

    # Infos
    for line in ABOUT.strip().splitlines():
        logging.info('%s', line)

    # Manager
    with conf.getmanager() as mgr:
        shell = SieveShell(mgr, clobber=conf.clobber, confirm=conf.confirm)
        if exprs:
            for expr in exprs:
                shell.executeline(expr)
        elif script:
            shell.runscript(script)
        elif command:
            shell.execute(command, *args)
        else:
            shell.enter()
    return shell.retval


def main():
    """Call :func:`sievemgr` but catch exceptions."""
    try:
        retval = sievemgr()
    except SignalCaught as exc:
        logging.error(exc)
        signal.signal(SIGTERM, SIG_IGN)
        os.killpg(os.getpgrp(), SIGTERM)
        signal.signal(exc.signo, SIG_DFL)
        signal.raise_signal(exc.signo)
    except (GetoptError, UsageError) as err:
        logging.error(err)
        sys.exit(2)
    # pylint: disable=broad-exception-caught
    except Exception as err:
        if DEBUG:
            raise
        if isinstance(err, (socket.herror, socket.gaierror, ConnectionError)):
            logging.error('%s', err.args[1])
        elif isinstance(err, (FileExistsError, FileNotFoundError)):
            logging.error('%s: %s', err.filename, os.strerror(err.errno))
        elif isinstance(err, (MemoryError, ssl.SSLError, Error)):
            logging.error('%s', err)
        # Some modules use OSError wrong.
        # pylint: disable=no-member
        elif isinstance(err, OSError) and err.errno:
            logging.error('%s', os.strerror(err.errno))
        elif isinstance(err, subprocess.CalledProcessError):
            logging.error('%s: exited with status %d',
                          err.cmd[0], err.returncode)
        else:
            logging.exception(err)
        sys.exit(1)
    sys.exit(retval)


#
# Boilerplate
#

logging.getLogger(__name__).addHandler(logging.NullHandler())

if __name__ == '__main__':
    main()
